diff --git a/README.md b/README.md index 7e626d8978..a9308f6d57 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,7 @@ Open source, multi-provider, no telemetry, not tied to any cloud. - **Sub-tasks** - Agents can spawn sub-tasks that resume from the parent task's session. Useful for splitting a task that has grown too big, or producing several PRs from the same starting point. - **CLI passthrough** - Drop into raw agent CLI mode for direct terminal interaction with any supported agent, leverage the full power of their TUIs - **Workspace isolation** - Git worktrees prevent concurrent agents from conflicting +- **Multi-repository tasks** - Span a single task across multiple repositories, with one worktree per repo, per-repo branches, per-repo PRs, and per-repo grouping in the Changes panel and review dialog - **Flexible runtimes** - Run agents as local processes, in isolated Docker containers or in remote executors like sprites.dev - **Session management** - Resume and review agent conversations - **Stats** - Track your productivity with stats on the completed tasks, agent turns, etc diff --git a/apps/backend/cmd/kandev/adapters.go b/apps/backend/cmd/kandev/adapters.go index d89c4e701c..5bddee1aa7 100644 --- a/apps/backend/cmd/kandev/adapters.go +++ b/apps/backend/cmd/kandev/adapters.go @@ -101,6 +101,27 @@ func (a *lifecycleAdapter) LaunchAgent(ctx context.Context, req *executor.Launch RepoName: req.RepoName, } + // Multi-repo: forward the explicit repo list when the orchestrator built one. + if len(req.Repositories) > 0 { + specs := make([]lifecycle.RepoLaunchSpec, 0, len(req.Repositories)) + for _, r := range req.Repositories { + specs = append(specs, lifecycle.RepoLaunchSpec{ + RepositoryID: r.RepositoryID, + RepositoryPath: r.RepositoryPath, + RepositoryURL: r.RepositoryURL, + RepoName: r.RepoName, + BaseBranch: r.BaseBranch, + CheckoutBranch: r.CheckoutBranch, + WorktreeID: r.WorktreeID, + WorktreeBranchPrefix: r.WorktreeBranchPrefix, + PullBeforeWorktree: r.PullBeforeWorktree, + RepoSetupScript: r.RepoSetupScript, + RepoCleanupScript: r.RepoCleanupScript, + }) + } + launchReq.Repositories = specs + } + // Create the agentctl execution (does NOT start agent process) execution, err := a.mgr.Launch(ctx, launchReq) if err != nil { @@ -121,6 +142,23 @@ func (a *lifecycleAdapter) LaunchAgent(ctx context.Context, req *executor.Launch } } + // Surface per-repo worktree results from the prepare step so the orchestrator + // can persist N TaskEnvironmentRepo / TaskSessionWorktree rows when multi-repo. + var worktrees []executor.RepoWorktreeResult + if execution.PrepareResult != nil && len(execution.PrepareResult.Worktrees) > 0 { + worktrees = make([]executor.RepoWorktreeResult, 0, len(execution.PrepareResult.Worktrees)) + for _, w := range execution.PrepareResult.Worktrees { + worktrees = append(worktrees, executor.RepoWorktreeResult{ + RepositoryID: w.RepositoryID, + WorktreeID: w.WorktreeID, + WorktreeBranch: w.WorktreeBranch, + WorktreePath: w.WorktreePath, + MainRepoGitDir: w.MainRepoGitDir, + ErrorMessage: w.ErrorMessage, + }) + } + } + return &executor.LaunchAgentResponse{ AgentExecutionID: execution.ID, ContainerID: execution.ContainerID, @@ -130,6 +168,7 @@ func (a *lifecycleAdapter) LaunchAgent(ctx context.Context, req *executor.Launch WorktreeBranch: worktreeBranch, Metadata: execution.Metadata, PrepareResult: execution.PrepareResult, + Worktrees: worktrees, }, nil } @@ -354,7 +393,7 @@ func (a *lifecycleAdapter) GetGitLog(ctx context.Context, sessionID, baseCommit if agentClient == nil { return nil, nil } - return agentClient.GitLog(ctx, baseCommit, limit, targetBranch) + return agentClient.GitLog(ctx, baseCommit, limit, targetBranch, "") } // GetCumulativeDiff retrieves the cumulative diff for a session from baseCommit to HEAD. diff --git a/apps/backend/cmd/kandev/gateway.go b/apps/backend/cmd/kandev/gateway.go index 1f3256c0b9..f67653b541 100644 --- a/apps/backend/cmd/kandev/gateway.go +++ b/apps/backend/cmd/kandev/gateway.go @@ -143,8 +143,13 @@ func provideGateway( gitHandlers := agenthandlers.NewGitHandlers(lifecycleMgr, &sessionReaderAdapter{repo: taskRepo, logger: log}, log) if githubSvc != nil { - gitHandlers.SetOnPRCreated(func(ctx context.Context, sessionID, taskID, prURL, branch string) { - githubSvc.AssociatePRByURL(ctx, sessionID, taskID, prURL, branch) + gitHandlers.SetOnPRCreated(func(ctx context.Context, sessionID, taskID, prURL, branch, repo string) { + // Resolve the multi-repo subpath to a per-task repository_id so the + // resulting TaskPR / PRWatch rows are scoped per-repo. Empty `repo` + // (single-repo task) yields empty repositoryID and preserves legacy + // 1:1 task→PR semantics. + repositoryID := resolveRepositoryIDForSubpath(ctx, taskRepo, taskID, repo, log) + githubSvc.AssociatePRByURL(ctx, sessionID, taskID, repositoryID, prURL, branch) }) } gitHandlers.SetOnGitOperationFailed(func(ctx context.Context, sessionID, taskID, operation, errorOutput string) { diff --git a/apps/backend/cmd/kandev/helpers.go b/apps/backend/cmd/kandev/helpers.go index 9448132f4b..3c62ae128d 100644 --- a/apps/backend/cmd/kandev/helpers.go +++ b/apps/backend/cmd/kandev/helpers.go @@ -23,6 +23,7 @@ import ( "github.com/kandev/kandev/internal/agent/mcpconfig" agentsettingscontroller "github.com/kandev/kandev/internal/agent/settings/controller" agentsettingshandlers "github.com/kandev/kandev/internal/agent/settings/handlers" + "github.com/kandev/kandev/internal/agentctl/client" "github.com/kandev/kandev/internal/agentctl/tracing" analyticshandlers "github.com/kandev/kandev/internal/analytics/handlers" analyticsrepository "github.com/kandev/kandev/internal/analytics/repository" @@ -159,21 +160,22 @@ func appendSessionStateMessage(sessionID string, session *models.TaskSession, re return result } -// appendLiveGitStatusMessage adds a git status notification by querying agentctl for live status. -// Falls back to DB snapshot if no execution exists (for archived sessions). +// appendLiveGitStatusMessage adds git status notification(s) by querying +// agentctl for live status. Multi-repo workspaces emit one notification per +// repo (stamped with repository_name); single-repo emits a single untagged +// notification. Falls back to DB snapshot if no execution exists (archived +// sessions only — the snapshot is workspace-wide, not per-repo). func appendLiveGitStatusMessage(ctx context.Context, taskRepo *sqliterepo.Repository, lifecycleMgr *lifecycle.Manager, sessionID string, session *models.TaskSession, result []*ws.Message, log *logger.Logger) []*ws.Message { - // Try to get live git status from agentctl - if msg := tryGetLiveGitStatus(ctx, lifecycleMgr, sessionID, log); msg != nil { - return append(result, msg) + if msgs := tryGetLiveGitStatus(ctx, lifecycleMgr, sessionID, log); len(msgs) > 0 { + return append(result, msgs...) } - - // Fallback: try to load from DB snapshot (for archived sessions) return appendDBSnapshotGitStatus(ctx, taskRepo, sessionID, result, log) } // tryGetLiveGitStatus attempts to get live git status from agentctl. -// Returns a notification message if successful, nil otherwise. -func tryGetLiveGitStatus(ctx context.Context, lifecycleMgr *lifecycle.Manager, sessionID string, log *logger.Logger) *ws.Message { +// Returns one notification per repo (one entry for single-repo workspaces). +// Returns nil when the session has no live execution or agentctl is stuck. +func tryGetLiveGitStatus(ctx context.Context, lifecycleMgr *lifecycle.Manager, sessionID string, log *logger.Logger) []*ws.Message { if lifecycleMgr == nil { return nil } @@ -195,41 +197,63 @@ func tryGetLiveGitStatus(ctx context.Context, lifecycleMgr *lifecycle.Manager, s // Use bounded timeout to prevent blocking session hydration if agentctl is stuck. rpcCtx, cancel := context.WithTimeout(ctx, 2*time.Second) defer cancel() - status, err := agentClient.GetGitStatus(rpcCtx) + multi, err := agentClient.GetGitStatusMulti(rpcCtx) if err != nil { log.Debug("failed to get live git status, will fall back to DB snapshot", zap.String("session_id", sessionID), zap.Error(err)) return nil } - - if !status.Success { + if multi == nil || !multi.Success || len(multi.Repos) == 0 { return nil } + out := make([]*ws.Message, 0, len(multi.Repos)) + for _, repo := range multi.Repos { + if !repo.Status.Success { + continue + } + notification := buildGitStatusNotification(sessionID, repo.RepositoryName, repo.Status) + if notification != nil { + out = append(out, notification) + } + } + if len(out) == 0 { + return nil + } log.Debug("got live git status from agentctl", zap.String("session_id", sessionID), - zap.String("branch", status.Branch), - zap.Int("files_count", len(status.Files))) + zap.Int("repos", len(out))) + return out +} +// buildGitStatusNotification packages a single repo's status as a WS event +// the frontend can route through its existing git-status handler. The +// repository_name is stamped on the inner status payload so the frontend +// stores it under byEnvironmentRepo[envKey][repository_name]. +func buildGitStatusNotification(sessionID, repositoryName string, status client.GitStatusResult) *ws.Message { + statusPayload := map[string]interface{}{ + "branch": status.Branch, + "remote_branch": status.RemoteBranch, + "ahead": status.Ahead, + "behind": status.Behind, + "files": status.Files, + "modified": status.Modified, + "added": status.Added, + "deleted": status.Deleted, + "untracked": status.Untracked, + "renamed": status.Renamed, + "branch_additions": status.BranchAdditions, + "branch_deletions": status.BranchDeletions, + } + if repositoryName != "" { + statusPayload["repository_name"] = repositoryName + } gitEventData := map[string]interface{}{ "type": "status_update", "session_id": sessionID, "timestamp": status.Timestamp, - "status": map[string]interface{}{ - "branch": status.Branch, - "remote_branch": status.RemoteBranch, - "ahead": status.Ahead, - "behind": status.Behind, - "files": status.Files, - "modified": status.Modified, - "added": status.Added, - "deleted": status.Deleted, - "untracked": status.Untracked, - "renamed": status.Renamed, - "branch_additions": status.BranchAdditions, - "branch_deletions": status.BranchDeletions, - }, + "status": statusPayload, } notification, err := ws.NewNotification(ws.ActionSessionGitEvent, gitEventData) if err != nil { @@ -461,6 +485,52 @@ func registerRoutes(p routeParams) { } } +// resolvePrimaryTaskRepositoryID returns the primary (lowest-position) +// task_repositories.repository_id for a task, or "" if none / on error. +// Used by PR-import callbacks where the PR is associated with the task's +// primary repo (the one the task was created against). +func resolvePrimaryTaskRepositoryID(ctx context.Context, taskRepo *sqliterepo.Repository, taskID string, log *logger.Logger) string { + repo, err := taskRepo.GetPrimaryTaskRepository(ctx, taskID) + if err != nil { + log.Warn("primary task repository lookup failed", + zap.String("task_id", taskID), zap.Error(err)) + return "" + } + if repo == nil { + return "" + } + return repo.RepositoryID +} + +// resolveRepositoryIDForSubpath maps a multi-repo subpath name (e.g. +// "kandev") to its task_repositories.repository_id by joining with the +// repositories table on Name. Empty subpath falls back to the primary +// repository so single-repo tasks Just Work. Returns "" if no match — the +// caller will then write a legacy single-repo PR row. +func resolveRepositoryIDForSubpath(ctx context.Context, taskRepo *sqliterepo.Repository, taskID, subpath string, log *logger.Logger) string { + if subpath == "" { + return resolvePrimaryTaskRepositoryID(ctx, taskRepo, taskID, log) + } + repos, err := taskRepo.ListTaskRepositories(ctx, taskID) + if err != nil { + log.Warn("task repositories lookup failed", + zap.String("task_id", taskID), zap.Error(err)) + return "" + } + for _, link := range repos { + repo, err := taskRepo.GetRepository(ctx, link.RepositoryID) + if err != nil || repo == nil { + continue + } + if repo.Name == subpath { + return link.RepositoryID + } + } + log.Warn("no task repository matches subpath", + zap.String("task_id", taskID), zap.String("subpath", subpath)) + return "" +} + // registerTaskRoutes registers all task-related HTTP and WebSocket routes. func registerTaskRoutes(p routeParams, planService *taskservice.PlanService) { taskhandlers.RegisterWorkspaceRoutes(p.router, p.gateway.Dispatcher, p.taskSvc, p.log) @@ -469,7 +539,11 @@ func registerTaskRoutes(p routeParams, planService *taskservice.PlanService) { if p.services.GitHub != nil { ghSvc := p.services.GitHub taskH.SetOnTaskCreatedWithPR(func(ctx context.Context, taskID, sessionID, prURL, branch string) { - ghSvc.AssociatePRByURL(ctx, sessionID, taskID, prURL, branch) + // Task-create-from-PR runs once per task and the PR maps to the + // primary repository (first task_repository row). Resolve to that + // repository_id so the resulting TaskPR/PRWatch are scoped per-repo. + repositoryID := resolvePrimaryTaskRepositoryID(ctx, p.taskRepo, taskID, p.log) + ghSvc.AssociatePRByURL(ctx, sessionID, taskID, repositoryID, prURL, branch) }) } taskhandlers.RegisterRepositoryRoutes(p.router, p.gateway.Dispatcher, p.taskSvc, p.log) diff --git a/apps/backend/internal/agent/handlers/git_handlers.go b/apps/backend/internal/agent/handlers/git_handlers.go index b580beca39..3fae5190fb 100644 --- a/apps/backend/internal/agent/handlers/git_handlers.go +++ b/apps/backend/internal/agent/handlers/git_handlers.go @@ -15,9 +15,12 @@ import ( "go.uber.org/zap" ) -// PRCreatedCallback is called after a PR is successfully created. +// PRCreatedCallback is called after a PR is successfully created. `repo` is +// the multi-repo subpath (e.g. "kandev"); empty for single-repo workspaces. +// The callback uses it to scope the resulting TaskPR / PRWatch rows to the +// owning repository so the second repo's PR doesn't overwrite the first. // Parameters: ctx, sessionID, taskID, prURL, branch. -type PRCreatedCallback func(ctx context.Context, sessionID, taskID, prURL, branch string) +type PRCreatedCallback func(ctx context.Context, sessionID, taskID, prURL, branch, repo string) // GitOperationFailedCallback is called when a git operation fails. // Parameters: ctx, sessionID, taskID, operation name, error output. @@ -116,35 +119,45 @@ func (h *GitHandlers) RegisterHandlers(d *ws.Dispatcher) { d.RegisterFunc(ws.ActionSessionCumulativeDiff, h.wsCumulativeDiff) } -// GitPullRequest for worktree.pull action +// GitPullRequest for worktree.pull action. +// Repo is the multi-repo subpath (e.g. "kandev"); empty for single-repo workspaces. type GitPullRequest struct { SessionID string `json:"session_id"` Rebase bool `json:"rebase"` + Repo string `json:"repo,omitempty"` } -// GitPushRequest for worktree.push action +// GitPushRequest for worktree.push action. +// Repo is the multi-repo subpath (e.g. "kandev"); empty for single-repo workspaces. type GitPushRequest struct { SessionID string `json:"session_id"` Force bool `json:"force"` SetUpstream bool `json:"set_upstream"` + Repo string `json:"repo,omitempty"` } -// GitRebaseRequest for worktree.rebase action +// GitRebaseRequest for worktree.rebase action. +// Repo is the multi-repo subpath (e.g. "kandev"); empty for single-repo workspaces. type GitRebaseRequest struct { SessionID string `json:"session_id"` BaseBranch string `json:"base_branch"` + Repo string `json:"repo,omitempty"` } -// GitMergeRequest for worktree.merge action +// GitMergeRequest for worktree.merge action. +// Repo is the multi-repo subpath (e.g. "kandev"); empty for single-repo workspaces. type GitMergeRequest struct { SessionID string `json:"session_id"` BaseBranch string `json:"base_branch"` + Repo string `json:"repo,omitempty"` } -// GitAbortRequest for worktree.abort action +// GitAbortRequest for worktree.abort action. +// Repo is the multi-repo subpath (e.g. "kandev"); empty for single-repo workspaces. type GitAbortRequest struct { SessionID string `json:"session_id"` Operation string `json:"operation"` // "merge" or "rebase" + Repo string `json:"repo,omitempty"` } // GitCommitRequest for worktree.commit action @@ -153,58 +166,74 @@ type GitCommitRequest struct { Message string `json:"message"` StageAll bool `json:"stage_all"` Amend bool `json:"amend"` + // Multi-repo: subpath of the repo to commit in (e.g. "kandev"). Empty for + // single-repo workspaces. Required for multi-repo — committing at the task + // root fails because it isn't itself a git repo. + Repo string `json:"repo,omitempty"` } -// GitRenameBranchRequest for worktree.rename_branch action +// GitRenameBranchRequest for worktree.rename_branch action. +// Repo is the multi-repo subpath (e.g. "kandev"); empty for single-repo. type GitRenameBranchRequest struct { SessionID string `json:"session_id"` NewName string `json:"new_name"` + Repo string `json:"repo,omitempty"` } // GitStageRequest for worktree.stage action type GitStageRequest struct { SessionID string `json:"session_id"` - Paths []string `json:"paths"` // Empty = stage all + Paths []string `json:"paths"` // Empty = stage all + Repo string `json:"repo,omitempty"` // Multi-repo subpath; empty for single-repo } // GitUnstageRequest for worktree.unstage action type GitUnstageRequest struct { SessionID string `json:"session_id"` - Paths []string `json:"paths"` // Empty = unstage all + Paths []string `json:"paths"` // Empty = unstage all + Repo string `json:"repo,omitempty"` // Multi-repo subpath; empty for single-repo } // GitDiscardRequest for worktree.discard action type GitDiscardRequest struct { SessionID string `json:"session_id"` - Paths []string `json:"paths"` // Required - files to discard + Paths []string `json:"paths"` // Required - files to discard + Repo string `json:"repo,omitempty"` // Multi-repo subpath; empty for single-repo } -// GitCreatePRRequest for worktree.create_pr action +// GitCreatePRRequest for worktree.create_pr action. +// Repo is the multi-repo subpath (e.g. "kandev"); empty for single-repo +// workspaces. Without it, agentctl falls back to the workspace root which +// for multi-repo task workspaces isn't a git repo, so PR creation fails. type GitCreatePRRequest struct { SessionID string `json:"session_id"` Title string `json:"title"` Body string `json:"body"` BaseBranch string `json:"base_branch"` Draft bool `json:"draft"` + Repo string `json:"repo,omitempty"` } // GitRevertCommitRequest for worktree.revert_commit action type GitRevertCommitRequest struct { SessionID string `json:"session_id"` CommitSHA string `json:"commit_sha"` + Repo string `json:"repo,omitempty"` // Multi-repo subpath; empty for single-repo } // GitResetRequest for worktree.reset action type GitResetRequest struct { SessionID string `json:"session_id"` CommitSHA string `json:"commit_sha"` - Mode string `json:"mode"` // "soft", "mixed", or "hard" + Mode string `json:"mode"` // "soft", "mixed", or "hard" + Repo string `json:"repo,omitempty"` // Multi-repo subpath; empty for single-repo } // GitShowCommitRequest for session.commit_diff action type GitShowCommitRequest struct { SessionID string `json:"session_id"` CommitSHA string `json:"commit_sha"` + Repo string `json:"repo,omitempty"` // Multi-repo subpath; empty for single-repo } // wsPull handles worktree.pull action @@ -223,7 +252,7 @@ func (h *GitHandlers) wsPull(ctx context.Context, msg *ws.Message) (*ws.Message, return nil, err } - result, err := client.GitPull(ctx, req.Rebase) + result, err := client.GitPull(ctx, req.Rebase, req.Repo) if err != nil { return nil, fmt.Errorf("pull failed: %w", err) } @@ -248,7 +277,7 @@ func (h *GitHandlers) wsPush(ctx context.Context, msg *ws.Message) (*ws.Message, return nil, err } - result, err := client.GitPush(ctx, req.Force, req.SetUpstream) + result, err := client.GitPush(ctx, req.Force, req.SetUpstream, req.Repo) if err != nil { return nil, fmt.Errorf("push failed: %w", err) } @@ -276,7 +305,7 @@ func (h *GitHandlers) wsRebase(ctx context.Context, msg *ws.Message) (*ws.Messag return nil, err } - result, err := client.GitRebase(ctx, req.BaseBranch) + result, err := client.GitRebase(ctx, req.BaseBranch, req.Repo) if err != nil { return nil, fmt.Errorf("rebase failed: %w", err) } @@ -304,7 +333,7 @@ func (h *GitHandlers) wsMerge(ctx context.Context, msg *ws.Message) (*ws.Message return nil, err } - result, err := client.GitMerge(ctx, req.BaseBranch) + result, err := client.GitMerge(ctx, req.BaseBranch, req.Repo) if err != nil { return nil, fmt.Errorf("merge failed: %w", err) } @@ -332,7 +361,7 @@ func (h *GitHandlers) wsAbort(ctx context.Context, msg *ws.Message) (*ws.Message return nil, err } - result, err := client.GitAbort(ctx, req.Operation) + result, err := client.GitAbort(ctx, req.Operation, req.Repo) if err != nil { return nil, fmt.Errorf("abort failed: %w", err) } @@ -359,7 +388,7 @@ func (h *GitHandlers) wsCommit(ctx context.Context, msg *ws.Message) (*ws.Messag return nil, err } - result, err := client.GitCommit(ctx, req.Message, req.StageAll, req.Amend) + result, err := client.GitCommit(ctx, req.Message, req.StageAll, req.Amend, req.Repo) if err != nil { return nil, fmt.Errorf("commit failed: %w", err) } @@ -387,7 +416,7 @@ func (h *GitHandlers) wsRenameBranch(ctx context.Context, msg *ws.Message) (*ws. return nil, err } - result, err := client.GitRenameBranch(ctx, req.NewName) + result, err := client.GitRenameBranch(ctx, req.NewName, req.Repo) if err != nil { return nil, fmt.Errorf("rename branch failed: %w", err) } @@ -421,7 +450,7 @@ func (h *GitHandlers) wsReset(ctx context.Context, msg *ws.Message) (*ws.Message return nil, err } - result, err := client.GitReset(ctx, req.CommitSHA, req.Mode) + result, err := client.GitReset(ctx, req.CommitSHA, req.Mode, req.Repo) if err != nil { return nil, fmt.Errorf("reset failed: %w", err) } @@ -445,7 +474,7 @@ func (h *GitHandlers) wsStage(ctx context.Context, msg *ws.Message) (*ws.Message return nil, err } - result, err := client.GitStage(ctx, req.Paths) + result, err := client.GitStage(ctx, req.Paths, req.Repo) if err != nil { return nil, fmt.Errorf("stage failed: %w", err) } @@ -469,7 +498,7 @@ func (h *GitHandlers) wsUnstage(ctx context.Context, msg *ws.Message) (*ws.Messa return nil, err } - result, err := client.GitUnstage(ctx, req.Paths) + result, err := client.GitUnstage(ctx, req.Paths, req.Repo) if err != nil { return nil, fmt.Errorf("unstage failed: %w", err) } @@ -497,7 +526,7 @@ func (h *GitHandlers) wsDiscard(ctx context.Context, msg *ws.Message) (*ws.Messa return nil, err } - result, err := client.GitDiscard(ctx, req.Paths) + result, err := client.GitDiscard(ctx, req.Paths, req.Repo) if err != nil { return nil, fmt.Errorf("discard failed: %w", err) } @@ -524,12 +553,14 @@ func (h *GitHandlers) wsCreatePR(ctx context.Context, msg *ws.Message) (*ws.Mess return nil, err } - result, err := client.GitCreatePR(ctx, req.Title, req.Body, req.BaseBranch, req.Draft) + result, err := client.GitCreatePR(ctx, req.Title, req.Body, req.BaseBranch, req.Draft, req.Repo) if err != nil { return nil, fmt.Errorf("create PR failed: %w", err) } - // On success, notify callback to associate PR with task. + // On success, notify callback to associate PR with task. The repo subpath + // flows through so the orchestrator can scope the resulting TaskPR / + // PRWatch rows to the per-task repository_id. // Use a timeout-bound context so a stuck callback doesn't leak the goroutine. if result.Success && result.PRURL != "" && h.onPRCreated != nil { execution, ok := h.lifecycleMgr.GetExecutionBySessionID(req.SessionID) @@ -537,10 +568,11 @@ func (h *GitHandlers) wsCreatePR(ctx context.Context, msg *ws.Message) (*ws.Mess sessionID := req.SessionID taskID := execution.TaskID prURL := result.PRURL + repo := req.Repo go func() { callbackCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() - h.onPRCreated(callbackCtx, sessionID, taskID, prURL, "") + h.onPRCreated(callbackCtx, sessionID, taskID, prURL, "", repo) }() } } @@ -567,7 +599,7 @@ func (h *GitHandlers) wsRevertCommit(ctx context.Context, msg *ws.Message) (*ws. return nil, err } - result, err := client.GitRevertCommit(ctx, req.CommitSHA) + result, err := client.GitRevertCommit(ctx, req.CommitSHA, req.Repo) if err != nil { return nil, fmt.Errorf("revert commit failed: %w", err) } @@ -603,7 +635,7 @@ func (h *GitHandlers) wsCommitDiff(ctx context.Context, msg *ws.Message) (*ws.Me return nil, err } - result, err := client.GitShowCommit(ctx, req.CommitSHA) + result, err := client.GitShowCommit(ctx, req.CommitSHA, req.Repo) if err != nil { return nil, fmt.Errorf("show commit failed: %w", err) } @@ -702,7 +734,7 @@ func (h *GitHandlers) wsGitCommits(ctx context.Context, msg *ws.Message) (*ws.Me // Use target branch for dynamic merge-base calculation. // This ensures accurate commit filtering even after rebases. - result, err := agentClient.GitLog(ctx, baseCommit, req.Limit, targetBranch) + result, err := agentClient.GitLog(ctx, baseCommit, req.Limit, targetBranch, "") if err != nil { return nil, fmt.Errorf("git log failed: %w", err) } diff --git a/apps/backend/internal/agent/handlers/workspace_file_handlers.go b/apps/backend/internal/agent/handlers/workspace_file_handlers.go index 804cba4ec7..5c3d2f59fb 100644 --- a/apps/backend/internal/agent/handlers/workspace_file_handlers.go +++ b/apps/backend/internal/agent/handlers/workspace_file_handlers.go @@ -80,6 +80,7 @@ func (h *WorkspaceFileHandlers) wsGetFileContent(ctx context.Context, msg *ws.Me var req struct { SessionID string `json:"session_id"` Path string `json:"path"` + Repo string `json:"repo,omitempty"` } if err := msg.ParsePayload(&req); err != nil { @@ -106,7 +107,7 @@ func (h *WorkspaceFileHandlers) wsGetFileContent(ctx context.Context, msg *ws.Me } // Request file content from agentctl - response, err := client.RequestFileContent(ctx, req.Path) + response, err := client.RequestFileContent(ctx, req.Path, req.Repo) if err != nil { h.logger.Error("failed to get file content", zap.Error(err), zap.String("session_id", req.SessionID), zap.String("path", req.Path)) return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, fmt.Sprintf("Failed to get file content: %v", err), nil) @@ -121,6 +122,7 @@ func (h *WorkspaceFileHandlers) wsGetFileContentAtRef(ctx context.Context, msg * SessionID string `json:"session_id"` Path string `json:"path"` Ref string `json:"ref"` + Repo string `json:"repo,omitempty"` } if err := msg.ParsePayload(&req); err != nil { @@ -150,7 +152,7 @@ func (h *WorkspaceFileHandlers) wsGetFileContentAtRef(ctx context.Context, msg * } // Request file content at ref from agentctl - response, err := client.RequestFileContentAtRef(ctx, req.Path, req.Ref) + response, err := client.RequestFileContentAtRef(ctx, req.Path, req.Ref, req.Repo) if err != nil { // "File not found at ref" is expected for new files - log as debug, not error errMsg := err.Error() @@ -173,6 +175,7 @@ func (h *WorkspaceFileHandlers) wsUpdateFileContent(ctx context.Context, msg *ws Diff string `json:"diff"` OriginalHash string `json:"original_hash"` DesiredContent *string `json:"desired_content"` + Repo string `json:"repo,omitempty"` } if err := msg.ParsePayload(&req); err != nil { @@ -202,7 +205,7 @@ func (h *WorkspaceFileHandlers) wsUpdateFileContent(ctx context.Context, msg *ws } // Apply file diff via agentctl - response, err := client.ApplyFileDiff(ctx, req.Path, req.Diff, req.OriginalHash, req.DesiredContent) + response, err := client.ApplyFileDiff(ctx, req.Path, req.Diff, req.OriginalHash, req.Repo, req.DesiredContent) if err != nil { h.logger.Error("failed to apply file diff", zap.Error(err), zap.String("session_id", req.SessionID), zap.String("path", req.Path)) return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, fmt.Sprintf("Failed to apply file diff: %v", err), nil) @@ -215,46 +218,47 @@ func (h *WorkspaceFileHandlers) wsUpdateFileContent(ctx context.Context, msg *ws // and returns the agentctl client, or an error response. func (h *WorkspaceFileHandlers) resolveSessionFileClient( ctx context.Context, msg *ws.Message, -) (sessionID, path string, client *agentctl.Client, errResp *ws.Message) { +) (sessionID, path, repo string, client *agentctl.Client, errResp *ws.Message) { var req struct { SessionID string `json:"session_id"` Path string `json:"path"` + Repo string `json:"repo,omitempty"` } if err := msg.ParsePayload(&req); err != nil { errResp, _ = ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil) - return "", "", nil, errResp + return "", "", "", nil, errResp } if req.SessionID == "" { errResp, _ = ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "session_id is required", nil) - return "", "", nil, errResp + return "", "", "", nil, errResp } if req.Path == "" { errResp, _ = ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "path is required", nil) - return "", "", nil, errResp + return "", "", "", nil, errResp } execution, resolveErr := h.lifecycle.GetOrEnsureExecution(ctx, req.SessionID) if resolveErr != nil { errResp, _ = ws.NewError(msg.ID, msg.Action, ws.ErrorCodeNotFound, "No agent found for session: "+resolveErr.Error(), nil) - return "", "", nil, errResp + return "", "", "", nil, errResp } c := execution.GetAgentCtlClient() if c == nil { errResp, _ = ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Agent client not available", nil) - return "", "", nil, errResp + return "", "", "", nil, errResp } - return req.SessionID, req.Path, c, nil + return req.SessionID, req.Path, req.Repo, c, nil } // wsCreateFile handles workspace.file.create action func (h *WorkspaceFileHandlers) wsCreateFile(ctx context.Context, msg *ws.Message) (*ws.Message, error) { - sessionID, path, client, errResp := h.resolveSessionFileClient(ctx, msg) + sessionID, path, repo, client, errResp := h.resolveSessionFileClient(ctx, msg) if errResp != nil { return errResp, nil } - response, err := client.CreateFile(ctx, path) + response, err := client.CreateFile(ctx, path, repo) if err != nil { h.logger.Error("failed to create file", zap.Error(err), zap.String("session_id", sessionID), zap.String("path", path)) return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, fmt.Sprintf("Failed to create file: %v", err), nil) @@ -265,12 +269,12 @@ func (h *WorkspaceFileHandlers) wsCreateFile(ctx context.Context, msg *ws.Messag // wsDeleteFile handles workspace.file.delete action func (h *WorkspaceFileHandlers) wsDeleteFile(ctx context.Context, msg *ws.Message) (*ws.Message, error) { - sessionID, path, client, errResp := h.resolveSessionFileClient(ctx, msg) + sessionID, path, repo, client, errResp := h.resolveSessionFileClient(ctx, msg) if errResp != nil { return errResp, nil } - response, err := client.DeleteFile(ctx, path) + response, err := client.DeleteFile(ctx, path, repo) if err != nil { h.logger.Error("failed to delete file", zap.Error(err), zap.String("session_id", sessionID), zap.String("path", path)) return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, fmt.Sprintf("Failed to delete file: %v", err), nil) @@ -329,6 +333,7 @@ func (h *WorkspaceFileHandlers) wsRenameFile(ctx context.Context, msg *ws.Messag SessionID string `json:"session_id"` OldPath string `json:"old_path"` NewPath string `json:"new_path"` + Repo string `json:"repo,omitempty"` } if err := msg.ParsePayload(&req); err != nil { @@ -358,7 +363,7 @@ func (h *WorkspaceFileHandlers) wsRenameFile(ctx context.Context, msg *ws.Messag } // Rename file via agentctl - response, err := client.RenameFile(ctx, req.OldPath, req.NewPath) + response, err := client.RenameFile(ctx, req.OldPath, req.NewPath, req.Repo) if err != nil { h.logger.Error("failed to rename file", zap.Error(err), zap.String("session_id", req.SessionID), diff --git a/apps/backend/internal/agent/lifecycle/env_preparer.go b/apps/backend/internal/agent/lifecycle/env_preparer.go index edee188546..e9b7a5b6d0 100644 --- a/apps/backend/internal/agent/lifecycle/env_preparer.go +++ b/apps/backend/internal/agent/lifecycle/env_preparer.go @@ -20,6 +20,23 @@ const ( PrepareStepSkipped PrepareStepStatus = "skipped" ) +// RepoPrepareSpec describes one repository for multi-repo environment preparation. +// Mirrors the per-repo prepare fields that EnvPrepareRequest historically +// carried at the top level. When EnvPrepareRequest.Repositories is non-empty, +// each entry produces one prepared worktree under the shared TaskDirName. +type RepoPrepareSpec struct { + RepositoryID string + RepositoryPath string + RepoName string + BaseBranch string + CheckoutBranch string + WorktreeID string + WorktreeBranch string + WorktreeBranchPrefix string + PullBeforeWorktree bool + RepoSetupScript string +} + // EnvPrepareRequest contains the parameters for environment preparation. type EnvPrepareRequest struct { TaskID string @@ -44,9 +61,40 @@ type EnvPrepareRequest struct { TaskDirName string // Per-task directory name within the workspace (e.g. "task-abc123") RepoName string // Repository slug used with TaskDirName to locate checkouts + // Repositories carries one entry per repository when the request is + // multi-repo. When non-empty it is the source of truth; the legacy + // single-repo top-level fields above are populated from Repositories[0] + // for callers that have not yet been updated. + Repositories []RepoPrepareSpec + Env map[string]string } +// RepoSpecs returns the per-repo prepare specs for this request. When +// Repositories is set it is returned verbatim; otherwise a length-1 list is +// synthesized from the legacy top-level single-repo fields. Returns an empty +// slice for repo-less requests. +func (r *EnvPrepareRequest) RepoSpecs() []RepoPrepareSpec { + if len(r.Repositories) > 0 { + return r.Repositories + } + if r.RepositoryID == "" && r.RepositoryPath == "" { + return nil + } + return []RepoPrepareSpec{{ + RepositoryID: r.RepositoryID, + RepositoryPath: r.RepositoryPath, + RepoName: r.RepoName, + BaseBranch: r.BaseBranch, + CheckoutBranch: r.CheckoutBranch, + WorktreeID: r.WorktreeID, + WorktreeBranch: r.WorktreeBranch, + WorktreeBranchPrefix: r.WorktreeBranchPrefix, + PullBeforeWorktree: r.PullBeforeWorktree, + RepoSetupScript: r.RepoSetupScript, + }} +} + // PrepareStep represents a single step in the preparation process. type PrepareStep struct { Name string `json:"name"` @@ -60,6 +108,18 @@ type PrepareStep struct { EndedAt *time.Time `json:"ended_at,omitempty"` } +// RepoWorktreeResult is the per-repository outcome of environment preparation. +// Populated by preparers that handle multi-repo launches; each entry corresponds +// to one RepoPrepareSpec from the request. +type RepoWorktreeResult struct { + RepositoryID string `json:"repository_id"` + WorktreeID string `json:"worktree_id,omitempty"` + WorktreeBranch string `json:"worktree_branch,omitempty"` + WorktreePath string `json:"worktree_path,omitempty"` + MainRepoGitDir string `json:"main_repo_git_dir,omitempty"` + ErrorMessage string `json:"error_message,omitempty"` +} + // EnvPrepareResult contains the result of environment preparation. type EnvPrepareResult struct { Success bool `json:"success"` @@ -68,10 +128,15 @@ type EnvPrepareResult struct { ErrorMessage string `json:"error_message,omitempty"` Duration time.Duration `json:"duration"` - // Worktree fields (populated when worktree preparer runs) + // Worktree fields (populated when worktree preparer runs). + // Legacy single-worktree fields; for multi-repo results they mirror Worktrees[0]. WorktreeID string `json:"worktree_id,omitempty"` WorktreeBranch string `json:"worktree_branch,omitempty"` MainRepoGitDir string `json:"main_repo_git_dir,omitempty"` + + // Worktrees is the per-repository outcome list when the preparer ran in + // multi-repo mode. Empty for single-repo or repo-less results. + Worktrees []RepoWorktreeResult `json:"worktrees,omitempty"` } // PrepareProgressCallback is called when a preparation step changes status. diff --git a/apps/backend/internal/agent/lifecycle/env_preparer_worktree.go b/apps/backend/internal/agent/lifecycle/env_preparer_worktree.go index 2168e2d438..a0ef0b8a8d 100644 --- a/apps/backend/internal/agent/lifecycle/env_preparer_worktree.go +++ b/apps/backend/internal/agent/lifecycle/env_preparer_worktree.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "path/filepath" + "strings" "time" "go.uber.org/zap" @@ -30,6 +31,13 @@ func NewWorktreePreparer(worktreeMgr *worktree.Manager, log *logger.Logger) *Wor func (p *WorktreePreparer) Name() string { return "worktree" } func (p *WorktreePreparer) Prepare(ctx context.Context, req *EnvPrepareRequest, onProgress PrepareProgressCallback) (*EnvPrepareResult, error) { + // Multi-repo (>=2 explicit specs) takes a dedicated path that creates one + // worktree per repo and rolls back on partial failure. Single-repo and + // repo-less requests use the original sequence unchanged. + if specs := req.Repositories; len(specs) >= 2 { + return p.prepareMultiRepo(ctx, req, specs, onProgress) + } + start := time.Now() var steps []PrepareStep @@ -211,6 +219,225 @@ func finalizeSyncStep( return steps } +// prepareMultiRepo prepares N worktrees, one per spec. On any per-repo +// failure, all already-created worktrees for this preparation are rolled back +// and the request is reported as failed with the originating error. +// +// Per-repo setup scripts (RepoSetupScript) run as a separate step after each +// repo's worktree is created. The task-level setup script (req.SetupScript) +// is intentionally NOT run from this path — that resolution depends on the +// task root layout and is handled by the caller in a future phase. +// +// On success the legacy single-worktree fields on EnvPrepareResult mirror +// Worktrees[0] so existing downstream code (manager_launch.launchApplyPrepareResult, +// orchestrator persistence) continues to consume the first repo's worktree +// without changes. +func (p *WorktreePreparer) prepareMultiRepo( + ctx context.Context, + req *EnvPrepareRequest, + specs []RepoPrepareSpec, + onProgress PrepareProgressCallback, +) (*EnvPrepareResult, error) { + start := time.Now() + var steps []PrepareStep + + if p.worktreeMgr == nil { + step := beginStep("Validate repositories") + completeStepError(&step, "worktree manager not available") + steps = append(steps, step) + reportProgress(onProgress, step, 0, 1) + return &EnvPrepareResult{ + Success: false, + Steps: steps, + ErrorMessage: step.Error, + Duration: time.Since(start), + }, nil + } + + // Step budget: per-repo (validate + create + optional sync + optional checkout + optional setup script). + totalSteps := 0 + for _, spec := range specs { + totalSteps += 2 + if spec.PullBeforeWorktree { + totalSteps++ + } + if spec.CheckoutBranch != "" { + totalSteps++ + } + if strings.TrimSpace(spec.RepoSetupScript) != "" { + totalSteps++ + } + } + + stepIdx := 0 + worktrees := make([]RepoWorktreeResult, 0, len(specs)) + createdIDs := make([]string, 0, len(specs)) + + for _, spec := range specs { + wt, newSteps, nextIdx, err := p.prepareOneRepo(ctx, req, spec, stepIdx, totalSteps, onProgress, steps) + steps = newSteps + stepIdx = nextIdx + if err != nil { + p.rollbackWorktrees(ctx, createdIDs) + return &EnvPrepareResult{ + Success: false, + Steps: steps, + ErrorMessage: err.Error(), + Duration: time.Since(start), + }, nil + } + createdIDs = append(createdIDs, wt.ID) + worktrees = append(worktrees, RepoWorktreeResult{ + RepositoryID: spec.RepositoryID, + WorktreeID: wt.ID, + WorktreeBranch: wt.Branch, + WorktreePath: wt.Path, + MainRepoGitDir: filepath.Join(spec.RepositoryPath, ".git"), + }) + } + + // Workspace path = task root (parent of any repo subdir). All repos share + // the same TaskDirName, so any worktree's parent works. + workspacePath := "" + if len(worktrees) > 0 { + workspacePath = filepath.Dir(worktrees[0].WorktreePath) + } + + res := &EnvPrepareResult{ + Success: true, + Steps: steps, + WorkspacePath: workspacePath, + Duration: time.Since(start), + Worktrees: worktrees, + } + // Mirror first repo into legacy fields for downstream consumers that + // haven't been migrated yet. + if len(worktrees) > 0 { + res.WorktreeID = worktrees[0].WorktreeID + res.WorktreeBranch = worktrees[0].WorktreeBranch + res.MainRepoGitDir = worktrees[0].MainRepoGitDir + } + return res, nil +} + +// prepareOneRepo runs the validate → create-worktree → fetch-PR → setup-script +// sequence for a single repo within a multi-repo preparation. Returns the +// created worktree, updated steps, the next step index, and any error. +func (p *WorktreePreparer) prepareOneRepo( + ctx context.Context, + req *EnvPrepareRequest, + spec RepoPrepareSpec, + stepIdx, totalSteps int, + onProgress PrepareProgressCallback, + steps []PrepareStep, +) (*worktree.Worktree, []PrepareStep, int, error) { + repoLabel := spec.RepoName + if repoLabel == "" { + repoLabel = spec.RepositoryID + } + + // Validate + validateStep := beginStep(fmt.Sprintf("Validate %s repository", repoLabel)) + reportProgress(onProgress, validateStep, stepIdx, totalSteps) + if spec.RepositoryPath == "" { + completeStepError(&validateStep, "no repository path provided") + steps = append(steps, validateStep) + reportProgress(onProgress, validateStep, stepIdx, totalSteps) + return nil, steps, stepIdx + 1, fmt.Errorf("repo %q: no repository path", repoLabel) + } + completeStepSuccess(&validateStep) + steps = append(steps, validateStep) + reportProgress(onProgress, validateStep, stepIdx, totalSteps) + stepIdx++ + + // Sync (optional) + Create — reuses single-repo helper by translating spec→req. + subReq := *req + subReq.RepositoryID = spec.RepositoryID + subReq.RepositoryPath = spec.RepositoryPath + subReq.RepoName = spec.RepoName + subReq.BaseBranch = spec.BaseBranch + subReq.CheckoutBranch = spec.CheckoutBranch + subReq.WorktreeID = spec.WorktreeID + subReq.WorktreeBranchPrefix = spec.WorktreeBranchPrefix + subReq.PullBeforeWorktree = spec.PullBeforeWorktree + // Strip the multi-repo list to avoid re-entering the multi-repo branch. + subReq.Repositories = nil + + wt, steps, stepIdx, err := p.createWorktreeWithSync(ctx, &subReq, stepIdx, totalSteps, onProgress, steps) + if err != nil { + return nil, steps, stepIdx, err + } + + // PR fetch step (mirrors single-repo path). + if spec.CheckoutBranch != "" { + step := beginStep(fmt.Sprintf("Fetch PR branch (%s)", repoLabel)) + step.Command = fmt.Sprintf("git fetch origin %s", spec.CheckoutBranch) + reportProgress(onProgress, step, stepIdx, totalSteps) + if wt.FetchWarning != "" { + step.Warning = wt.FetchWarning + step.WarningDetail = wt.FetchWarningDetail + } + completeStepSuccess(&step) + steps = append(steps, step) + reportProgress(onProgress, step, stepIdx, totalSteps) + stepIdx++ + } + + // Per-repo setup script (passed verbatim — no script-engine resolution + // in this phase; that lands with the per-repo placeholder namespace later). + if script := strings.TrimSpace(spec.RepoSetupScript); script != "" { + var runErr error + steps, runErr = runRepoSetupScriptStep(ctx, &subReq, repoLabel, wt.Path, script, stepIdx, totalSteps, onProgress, steps) + if runErr != nil { + return nil, steps, stepIdx + 1, fmt.Errorf("repo %q setup script failed: %w", repoLabel, runErr) + } + stepIdx++ + } + + return wt, steps, stepIdx, nil +} + +// runRepoSetupScriptStep executes one per-repo setup script and appends the +// step (with streaming output) to steps. Mirrors runSetupScriptStep but uses a +// repo-scoped step name and returns any execution error to the caller for +// rollback control. +func runRepoSetupScriptStep( + ctx context.Context, + req *EnvPrepareRequest, + repoLabel, workspacePath, script string, + stepIdx, totalSteps int, + onProgress PrepareProgressCallback, + steps []PrepareStep, +) ([]PrepareStep, error) { + step := beginStep(fmt.Sprintf("Run setup script (%s)", repoLabel)) + step.Command = setupScriptDisplayCommand(req) + reportProgress(onProgress, step, stepIdx, totalSteps) + output, err := runSetupScript(ctx, script, workspacePath, req.Env, func(current string) { + step.Output = current + reportProgress(onProgress, step, stepIdx, totalSteps) + }) + step.Output = output + if err != nil { + completeStepError(&step, err.Error()) + } else { + completeStepSuccess(&step) + } + steps = append(steps, step) + reportProgress(onProgress, step, stepIdx, totalSteps) + return steps, err +} + +// rollbackWorktrees removes any worktrees created during a failed multi-repo +// preparation. Best effort: failures are logged and do not interrupt teardown. +func (p *WorktreePreparer) rollbackWorktrees(ctx context.Context, ids []string) { + for _, id := range ids { + if err := p.worktreeMgr.RemoveByID(ctx, id, false); err != nil { + p.logger.Warn("rollback: failed to remove worktree", + zap.String("worktree_id", id), zap.Error(err)) + } + } +} + func applySyncProgressEvent(step *PrepareStep, event worktree.SyncProgressEvent) { if event.StepName != "" { step.Name = event.StepName diff --git a/apps/backend/internal/agent/lifecycle/env_preparer_worktree_multi_test.go b/apps/backend/internal/agent/lifecycle/env_preparer_worktree_multi_test.go new file mode 100644 index 0000000000..9ecc2e16df --- /dev/null +++ b/apps/backend/internal/agent/lifecycle/env_preparer_worktree_multi_test.go @@ -0,0 +1,327 @@ +package lifecycle + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "strings" + "sync" + "testing" + + "github.com/kandev/kandev/internal/agent/executor" + "github.com/kandev/kandev/internal/common/logger" + "github.com/kandev/kandev/internal/worktree" +) + +// inMemoryWorktreeStore is a minimal worktree.Store for preparer tests. +// Mirrors the package-internal mockStore, but lives here so tests don't need +// to import worktree-internal symbols. +type inMemoryWorktreeStore struct { + mu sync.Mutex + worktrees map[string]*worktree.Worktree +} + +func newInMemoryWorktreeStore() *inMemoryWorktreeStore { + return &inMemoryWorktreeStore{worktrees: make(map[string]*worktree.Worktree)} +} + +func (s *inMemoryWorktreeStore) CreateWorktree(_ context.Context, wt *worktree.Worktree) error { + s.mu.Lock() + defer s.mu.Unlock() + s.worktrees[wt.ID] = wt + return nil +} + +func (s *inMemoryWorktreeStore) GetWorktreeByID(_ context.Context, id string) (*worktree.Worktree, error) { + s.mu.Lock() + defer s.mu.Unlock() + wt, ok := s.worktrees[id] + if !ok { + return nil, nil + } + return wt, nil +} + +func (s *inMemoryWorktreeStore) GetWorktreeBySessionID(_ context.Context, sessionID string) (*worktree.Worktree, error) { + s.mu.Lock() + defer s.mu.Unlock() + for _, wt := range s.worktrees { + if wt.SessionID == sessionID { + return wt, nil + } + } + return nil, nil +} + +func (s *inMemoryWorktreeStore) GetWorktreesByTaskID(_ context.Context, taskID string) ([]*worktree.Worktree, error) { + s.mu.Lock() + defer s.mu.Unlock() + var out []*worktree.Worktree + for _, wt := range s.worktrees { + if wt.TaskID == taskID { + out = append(out, wt) + } + } + return out, nil +} + +func (s *inMemoryWorktreeStore) GetWorktreesByRepositoryID(_ context.Context, repoID string) ([]*worktree.Worktree, error) { + s.mu.Lock() + defer s.mu.Unlock() + var out []*worktree.Worktree + for _, wt := range s.worktrees { + if wt.RepositoryID == repoID { + out = append(out, wt) + } + } + return out, nil +} + +func (s *inMemoryWorktreeStore) UpdateWorktree(_ context.Context, wt *worktree.Worktree) error { + s.mu.Lock() + defer s.mu.Unlock() + s.worktrees[wt.ID] = wt + return nil +} + +func (s *inMemoryWorktreeStore) DeleteWorktree(_ context.Context, id string) error { + s.mu.Lock() + defer s.mu.Unlock() + delete(s.worktrees, id) + return nil +} + +func (s *inMemoryWorktreeStore) ListActiveWorktrees(_ context.Context) ([]*worktree.Worktree, error) { + s.mu.Lock() + defer s.mu.Unlock() + var out []*worktree.Worktree + for _, wt := range s.worktrees { + if wt.Status == worktree.StatusActive { + out = append(out, wt) + } + } + return out, nil +} + +// GetWorktreesBySessionID — MultiRepoStore. +func (s *inMemoryWorktreeStore) GetWorktreesBySessionID(_ context.Context, sessionID string) ([]*worktree.Worktree, error) { + s.mu.Lock() + defer s.mu.Unlock() + var out []*worktree.Worktree + for _, wt := range s.worktrees { + if wt.SessionID == sessionID && wt.Status == worktree.StatusActive { + out = append(out, wt) + } + } + return out, nil +} + +// GetWorktreeBySessionAndRepository — MultiRepoStore. +func (s *inMemoryWorktreeStore) GetWorktreeBySessionAndRepository(_ context.Context, sessionID, repoID string) (*worktree.Worktree, error) { + s.mu.Lock() + defer s.mu.Unlock() + for _, wt := range s.worktrees { + if wt.SessionID == sessionID && wt.RepositoryID == repoID && wt.Status == worktree.StatusActive { + return wt, nil + } + } + return nil, nil +} + +// initBareGitRepo creates a single-commit git repo in a temp directory and +// returns its path. Used as a stand-in for a "real" repository in WorktreePreparer +// multi-repo tests. +func initBareGitRepo(t *testing.T, name string) string { + t.Helper() + dir := filepath.Join(t.TempDir(), name) + if err := os.MkdirAll(dir, 0755); err != nil { + t.Fatalf("mkdir: %v", err) + } + mustGit := func(args ...string) { + cmd := exec.Command("git", args...) + cmd.Dir = dir + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("git %s in %s failed: %v\n%s", strings.Join(args, " "), dir, err, out) + } + } + mustGit("init", "-b", "main") + mustGit("config", "user.email", "test@example.com") + mustGit("config", "user.name", "Test User") + mustGit("config", "commit.gpgsign", "false") + if err := os.WriteFile(filepath.Join(dir, "README.md"), []byte("hello "+name), 0644); err != nil { + t.Fatalf("write README: %v", err) + } + mustGit("add", "README.md") + mustGit("commit", "-m", "initial commit") + return dir +} + +func newPreparerForTest(t *testing.T) (*WorktreePreparer, *worktree.Manager) { + t.Helper() + tmp := t.TempDir() + cfg := worktree.Config{ + Enabled: true, + TasksBasePath: filepath.Join(tmp, "tasks"), + BranchPrefix: "feat/", + } + log, _ := logger.NewLogger(logger.LoggingConfig{Level: "error", Format: "json"}) + store := newInMemoryWorktreeStore() + mgr, err := worktree.NewManager(cfg, store, log) + if err != nil { + t.Fatalf("worktree manager: %v", err) + } + return NewWorktreePreparer(mgr, log), mgr +} + +func TestWorktreePreparer_MultiRepo_CreatesWorktreePerRepo(t *testing.T) { + repoA := initBareGitRepo(t, "frontend") + repoB := initBareGitRepo(t, "backend") + + preparer, _ := newPreparerForTest(t) + + req := &EnvPrepareRequest{ + TaskID: "task-multi-1", + SessionID: "sess-multi-1", + TaskTitle: "Multi Repo Task", + ExecutorType: executor.Name("worktree"), + TaskDirName: "multi-repo-task_aaa", + Repositories: []RepoPrepareSpec{ + {RepositoryID: "repo-front", RepositoryPath: repoA, RepoName: "frontend", BaseBranch: "main"}, + {RepositoryID: "repo-back", RepositoryPath: repoB, RepoName: "backend", BaseBranch: "main"}, + }, + } + + res, err := preparer.Prepare(context.Background(), req, nil) + if err != nil { + t.Fatalf("prepare: %v", err) + } + if !res.Success { + t.Fatalf("expected success; steps: %+v err: %s", res.Steps, res.ErrorMessage) + } + if len(res.Worktrees) != 2 { + t.Fatalf("expected 2 worktrees, got %d", len(res.Worktrees)) + } + if res.Worktrees[0].RepositoryID != "repo-front" || res.Worktrees[1].RepositoryID != "repo-back" { + t.Errorf("unexpected order: %+v", res.Worktrees) + } + // Legacy single-worktree fields mirror Worktrees[0]. + if res.WorktreeID != res.Worktrees[0].WorktreeID { + t.Errorf("legacy WorktreeID should mirror Worktrees[0]; got %q vs %q", res.WorktreeID, res.Worktrees[0].WorktreeID) + } + // WorkspacePath = parent of any repo subdir (the task root). + if res.WorkspacePath == "" { + t.Error("expected workspace_path to be set") + } + // Both worktrees should live under the same task root. + if filepath.Dir(res.Worktrees[0].WorktreePath) != filepath.Dir(res.Worktrees[1].WorktreePath) { + t.Errorf("worktrees should share task root: %s vs %s", + res.Worktrees[0].WorktreePath, res.Worktrees[1].WorktreePath) + } + // Each worktree directory must exist on disk. + for _, w := range res.Worktrees { + if _, err := os.Stat(w.WorktreePath); err != nil { + t.Errorf("worktree dir missing for %s: %v", w.RepositoryID, err) + } + } +} + +func TestWorktreePreparer_MultiRepo_RollbackOnPartialFailure(t *testing.T) { + repoA := initBareGitRepo(t, "good") + // repoB is intentionally a non-git directory to force the second create to fail. + repoB := t.TempDir() + + preparer, mgr := newPreparerForTest(t) + + req := &EnvPrepareRequest{ + TaskID: "task-multi-fail", + SessionID: "sess-multi-fail", + TaskTitle: "Failing Task", + ExecutorType: executor.Name("worktree"), + TaskDirName: "failing-task_bbb", + Repositories: []RepoPrepareSpec{ + {RepositoryID: "repo-good", RepositoryPath: repoA, RepoName: "good", BaseBranch: "main"}, + {RepositoryID: "repo-bad", RepositoryPath: repoB, RepoName: "bad", BaseBranch: "main"}, + }, + } + + res, err := preparer.Prepare(context.Background(), req, nil) + if err != nil { + t.Fatalf("prepare returned error (should report via result): %v", err) + } + if res.Success { + t.Fatal("expected failure when second repo is not a git repo") + } + if res.ErrorMessage == "" { + t.Error("expected non-empty error message") + } + + // Rollback: no worktrees should remain in the store after partial failure. + all, err := mgr.GetAllByTaskID(context.Background(), "task-multi-fail") + if err != nil { + t.Fatalf("list worktrees: %v", err) + } + for _, wt := range all { + if wt.Status == worktree.StatusActive { + t.Errorf("expected no active worktrees after rollback; found %s in %s", wt.ID, wt.Path) + } + } +} + +func TestWorktreePreparer_MultiRepo_RunsPerRepoSetupScript(t *testing.T) { + repoA := initBareGitRepo(t, "frontend") + repoB := initBareGitRepo(t, "backend") + + preparer, _ := newPreparerForTest(t) + + req := &EnvPrepareRequest{ + TaskID: "task-multi-setup", + SessionID: "sess-multi-setup", + TaskTitle: "Setup Task", + ExecutorType: executor.Name("worktree"), + TaskDirName: "setup-task_ccc", + Repositories: []RepoPrepareSpec{ + { + RepositoryID: "repo-front", + RepositoryPath: repoA, + RepoName: "frontend", + BaseBranch: "main", + RepoSetupScript: "echo front-script-ran > setup-marker.txt", + }, + { + RepositoryID: "repo-back", + RepositoryPath: repoB, + RepoName: "backend", + BaseBranch: "main", + RepoSetupScript: "echo back-script-ran > setup-marker.txt", + }, + }, + } + + res, err := preparer.Prepare(context.Background(), req, nil) + if err != nil { + t.Fatalf("prepare: %v", err) + } + if !res.Success { + t.Fatalf("expected success; err: %s", res.ErrorMessage) + } + if len(res.Worktrees) != 2 { + t.Fatalf("expected 2 worktrees; got %d", len(res.Worktrees)) + } + for i, w := range res.Worktrees { + marker := filepath.Join(w.WorktreePath, "setup-marker.txt") + if _, err := os.Stat(marker); err != nil { + t.Errorf("repo %d setup marker missing: %v", i, err) + } + } + // Each repo should produce a "Run setup script ()" step. + var setupStepsSeen int + for _, s := range res.Steps { + if strings.HasPrefix(s.Name, "Run setup script") { + setupStepsSeen++ + } + } + if setupStepsSeen != 2 { + t.Errorf("expected 2 per-repo setup script steps, got %d", setupStepsSeen) + } +} diff --git a/apps/backend/internal/agent/lifecycle/event_types.go b/apps/backend/internal/agent/lifecycle/event_types.go index 5ce4c00c26..7a6336cb46 100644 --- a/apps/backend/internal/agent/lifecycle/event_types.go +++ b/apps/backend/internal/agent/lifecycle/event_types.go @@ -225,6 +225,10 @@ type GitStatusData struct { Files interface{} `json:"files,omitempty"` BranchAdditions int `json:"branch_additions,omitempty"` BranchDeletions int `json:"branch_deletions,omitempty"` + // RepositoryName identifies which repository this status belongs to in + // multi-repo task workspaces. Empty for single-repo. Carried through to + // the frontend so the Changes panel can render per-repo group headers. + RepositoryName string `json:"repository_name,omitempty"` } type GitCommitData struct { @@ -239,12 +243,20 @@ type GitCommitData struct { Deletions int `json:"deletions"` CommittedAt string `json:"committed_at"` CreatedAt string `json:"created_at,omitempty"` + // RepositoryName identifies which repo this commit belongs to in multi-repo + // task workspaces. Empty for single-repo. Carried to the frontend so the + // Commits panel can render per-repo group headers. + RepositoryName string `json:"repository_name,omitempty"` } type GitResetData struct { PreviousHead string `json:"previous_head"` CurrentHead string `json:"current_head"` DeletedCount int `json:"deleted_count"` + // RepositoryName identifies which repo this reset belongs to in multi-repo + // task workspaces. Empty for single-repo. Carried to the frontend so the + // Changes panel can scope its per-repo group state. + RepositoryName string `json:"repository_name,omitempty"` } type GitBranchSwitchData struct { @@ -252,6 +264,10 @@ type GitBranchSwitchData struct { CurrentBranch string `json:"current_branch"` CurrentHead string `json:"current_head"` BaseCommit string `json:"base_commit"` + // RepositoryName identifies which repo this branch switch belongs to in + // multi-repo task workspaces. Empty for single-repo. Carried to the + // frontend so the Changes panel can update the matching per-repo group. + RepositoryName string `json:"repository_name,omitempty"` } type GitSnapshotData struct { @@ -277,6 +293,10 @@ type FileChangeEventPayload struct { Path string `json:"path"` Operation string `json:"operation"` Timestamp string `json:"timestamp"` + // RepositoryName identifies which repo emitted this file change in + // multi-repo task workspaces. Empty for single-repo. Carried through to + // the frontend so per-repo views can scope refresh signals correctly. + RepositoryName string `json:"repository_name,omitempty"` } // GetSessionID returns the session ID for this event (used by event routing). diff --git a/apps/backend/internal/agent/lifecycle/events.go b/apps/backend/internal/agent/lifecycle/events.go index 60b54997d6..ed66f0ea78 100644 --- a/apps/backend/internal/agent/lifecycle/events.go +++ b/apps/backend/internal/agent/lifecycle/events.go @@ -247,6 +247,7 @@ func (p *EventPublisher) PublishGitStatus(execution *AgentExecution, update *age Files: update.Files, BranchAdditions: update.BranchAdditions, BranchDeletions: update.BranchDeletions, + RepositoryName: update.RepositoryName, }, }) } @@ -260,15 +261,16 @@ func (p *EventPublisher) PublishGitCommit(execution *AgentExecution, commit *age AgentID: execution.ID, Timestamp: time.Now().Format(time.RFC3339Nano), Commit: &GitCommitData{ - CommitSHA: commit.CommitSHA, - ParentSHA: commit.ParentSHA, - Message: commit.Message, - AuthorName: commit.AuthorName, - AuthorEmail: commit.AuthorEmail, - FilesChanged: commit.FilesChanged, - Insertions: commit.Insertions, - Deletions: commit.Deletions, - CommittedAt: commit.CommittedAt.Format(time.RFC3339), + CommitSHA: commit.CommitSHA, + ParentSHA: commit.ParentSHA, + Message: commit.Message, + AuthorName: commit.AuthorName, + AuthorEmail: commit.AuthorEmail, + FilesChanged: commit.FilesChanged, + Insertions: commit.Insertions, + Deletions: commit.Deletions, + CommittedAt: commit.CommittedAt.Format(time.RFC3339), + RepositoryName: commit.RepositoryName, }, }) } @@ -282,8 +284,9 @@ func (p *EventPublisher) PublishGitReset(execution *AgentExecution, reset *agent AgentID: execution.ID, Timestamp: reset.Timestamp.Format(time.RFC3339Nano), Reset: &GitResetData{ - PreviousHead: reset.PreviousHead, - CurrentHead: reset.CurrentHead, + PreviousHead: reset.PreviousHead, + CurrentHead: reset.CurrentHead, + RepositoryName: reset.RepositoryName, }, }) } @@ -301,6 +304,7 @@ func (p *EventPublisher) PublishBranchSwitch(execution *AgentExecution, branchSw CurrentBranch: branchSwitch.CurrentBranch, CurrentHead: branchSwitch.CurrentHead, BaseCommit: branchSwitch.BaseCommit, + RepositoryName: branchSwitch.RepositoryName, }, }) } @@ -314,12 +318,13 @@ func (p *EventPublisher) PublishFileChange(execution *AgentExecution, notificati sessionID := execution.SessionID payload := FileChangeEventPayload{ - TaskID: execution.TaskID, - SessionID: sessionID, - AgentID: execution.ID, - Path: notification.Path, - Operation: notification.Operation, - Timestamp: notification.Timestamp.Format(time.RFC3339Nano), + TaskID: execution.TaskID, + SessionID: sessionID, + AgentID: execution.ID, + Path: notification.Path, + Operation: notification.Operation, + Timestamp: notification.Timestamp.Format(time.RFC3339Nano), + RepositoryName: notification.RepositoryName, } event := bus.NewEvent(events.FileChangeNotified, "agent-manager", payload) diff --git a/apps/backend/internal/agent/lifecycle/events_git_status_test.go b/apps/backend/internal/agent/lifecycle/events_git_status_test.go new file mode 100644 index 0000000000..7a7bef440e --- /dev/null +++ b/apps/backend/internal/agent/lifecycle/events_git_status_test.go @@ -0,0 +1,62 @@ +package lifecycle + +import ( + "context" + "testing" + "time" + + agentctl "github.com/kandev/kandev/internal/agentctl/client" + "github.com/kandev/kandev/internal/common/logger" + "github.com/kandev/kandev/internal/events" + "github.com/kandev/kandev/internal/events/bus" +) + +// Verifies the fix for "Changes panel shows no header / no existing changes". +// agentctl tags every per-repo GitStatusUpdate with RepositoryName; that field +// must survive the lifecycle PublishGitStatus translation so the orchestrator +// (and thus the frontend) sees it. +func TestPublishGitStatus_PropagatesRepositoryName(t *testing.T) { + log, _ := logger.NewLogger(logger.LoggingConfig{Level: "error", Format: "json"}) + eventBus := bus.NewMemoryEventBus(log) + pub := NewEventPublisher(eventBus, log) + + received := make(chan *bus.Event, 1) + subj := events.BuildGitEventSubject("sess-multi") + sub, err := eventBus.Subscribe(subj, func(_ context.Context, ev *bus.Event) error { + received <- ev + return nil + }) + if err != nil { + t.Fatalf("subscribe: %v", err) + } + defer func() { _ = sub.Unsubscribe() }() + + exec := &AgentExecution{ + ID: "exec-1", + TaskID: "task-1", + SessionID: "sess-multi", + } + pub.PublishGitStatus(exec, &agentctl.GitStatusUpdate{ + Timestamp: time.Now(), + RepositoryName: "frontend", + Branch: "feature/x", + Modified: []string{"src/app.tsx"}, + Files: map[string]agentctl.FileInfo{"src/app.tsx": {Path: "src/app.tsx"}}, + }) + + select { + case ev := <-received: + payload, ok := ev.Data.(*GitEventPayload) + if !ok || payload == nil { + t.Fatalf("expected *GitEventPayload, got %T", ev.Data) + } + if payload.Status == nil { + t.Fatal("expected non-nil Status on payload") + } + if payload.Status.RepositoryName != "frontend" { + t.Errorf("repository_name was dropped: got %q", payload.Status.RepositoryName) + } + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for git status event") + } +} diff --git a/apps/backend/internal/agent/lifecycle/executor_sprites_e2e_test.go b/apps/backend/internal/agent/lifecycle/executor_sprites_e2e_test.go index 1d38a45655..c7dd082ff4 100644 --- a/apps/backend/internal/agent/lifecycle/executor_sprites_e2e_test.go +++ b/apps/backend/internal/agent/lifecycle/executor_sprites_e2e_test.go @@ -128,7 +128,7 @@ func TestSpritesE2E_FullFlow(t *testing.T) { require.NotEmpty(t, results.Files) t.Logf("found %d files matching 'gin.go'", len(results.Files)) - content, err := client.RequestFileContent(ctx, "go.mod") + content, err := client.RequestFileContent(ctx, "go.mod", "") require.NoError(t, err) require.Contains(t, content.Content, "github.com/gin-gonic/gin") t.Logf("go.mod: %d bytes", len(content.Content)) @@ -320,11 +320,11 @@ func TestSpritesE2E_FullFlow(t *testing.T) { _, err = client.CreateFile(ctx, "e2e-change.txt") require.NoError(t, err) - result, err := client.GitStage(ctx, []string{"e2e-change.txt"}) + result, err := client.GitStage(ctx, []string{"e2e-change.txt"}, "") require.NoError(t, err) require.True(t, result.Success, "git stage should succeed: %s", result.Error) - result, err = client.GitCommit(ctx, "e2e test commit", false) + result, err = client.GitCommit(ctx, "e2e test commit", false, false, "") require.NoError(t, err) require.True(t, result.Success, "git commit should succeed: %s", result.Error) t.Logf("git commit: %s", result.Output) diff --git a/apps/backend/internal/agent/lifecycle/manager_launch.go b/apps/backend/internal/agent/lifecycle/manager_launch.go index 4613a2c525..3ed76dff24 100644 --- a/apps/backend/internal/agent/lifecycle/manager_launch.go +++ b/apps/backend/internal/agent/lifecycle/manager_launch.go @@ -391,6 +391,30 @@ func (m *Manager) runEnvironmentPreparer( RepoName: req.RepoName, Env: req.Env, } + // Multi-repo: forward the repo list when the launch request carries one. + // Each per-repo entry inherits the request-level RepoSetupScript when its + // own is empty so single-repo callers continue to work unchanged. + if len(req.Repositories) > 0 { + specs := make([]RepoPrepareSpec, 0, len(req.Repositories)) + for _, r := range req.Repositories { + setup := r.RepoSetupScript + if setup == "" { + setup = repoSetupScript + } + specs = append(specs, RepoPrepareSpec{ + RepositoryID: r.RepositoryID, + RepositoryPath: r.RepositoryPath, + RepoName: r.RepoName, + BaseBranch: r.BaseBranch, + CheckoutBranch: r.CheckoutBranch, + WorktreeID: r.WorktreeID, + WorktreeBranchPrefix: r.WorktreeBranchPrefix, + PullBeforeWorktree: r.PullBeforeWorktree, + RepoSetupScript: setup, + }) + } + prepReq.Repositories = specs + } result, err := preparer.Prepare(ctx, prepReq, m.newProgressCallback(req.TaskID, req.SessionID)) if err != nil { diff --git a/apps/backend/internal/agent/lifecycle/types.go b/apps/backend/internal/agent/lifecycle/types.go index c29fd0c02f..84ad80bf9b 100644 --- a/apps/backend/internal/agent/lifecycle/types.go +++ b/apps/backend/internal/agent/lifecycle/types.go @@ -250,6 +250,24 @@ func (ae *AgentExecution) EndSessionSpan() { } } +// RepoLaunchSpec describes one repository for a multi-repo task launch. +// Mirrors the per-repo launch fields that LaunchRequest historically carried at +// the top level. When LaunchRequest.Repositories is set, each entry produces +// one prepared worktree under the shared TaskDirName. +type RepoLaunchSpec struct { + RepositoryID string + RepositoryPath string + RepositoryURL string // Clone URL for remote executors that need to clone + RepoName string // Repository name used as subdirectory inside TaskDirName + BaseBranch string + CheckoutBranch string + WorktreeID string // Existing worktree ID to reuse (skip creation if set) + WorktreeBranchPrefix string + PullBeforeWorktree bool + RepoSetupScript string // Repository-level setup script (optional) + RepoCleanupScript string // Repository-level cleanup script (optional) +} + // LaunchRequest contains parameters for launching an agent type LaunchRequest struct { TaskID string @@ -291,6 +309,35 @@ type LaunchRequest struct { // Task directory mode: place worktree at ~/.kandev/tasks/{TaskDirName}/{RepoName}/ TaskDirName string // Semantic task directory name (e.g. "fix-bug_ab12") RepoName string // Repository name used as subdirectory inside the task directory + + // Repositories carries one entry per repository when the launch is multi-repo. + // When non-empty it is the source of truth; the legacy single-repo top-level + // fields above are populated from Repositories[0] for callers that have not + // yet been updated. + Repositories []RepoLaunchSpec +} + +// RepoSpecs returns the per-repo launch specs for this request. When +// Repositories is set it is returned verbatim; otherwise a length-1 list is +// synthesized from the legacy top-level single-repo fields. Returns an empty +// slice for repo-less launches (e.g. quick chat). +func (r *LaunchRequest) RepoSpecs() []RepoLaunchSpec { + if len(r.Repositories) > 0 { + return r.Repositories + } + if r.RepositoryID == "" && r.RepositoryPath == "" { + return nil + } + return []RepoLaunchSpec{{ + RepositoryID: r.RepositoryID, + RepositoryPath: r.RepositoryPath, + RepoName: r.RepoName, + BaseBranch: r.BaseBranch, + CheckoutBranch: r.CheckoutBranch, + WorktreeID: r.WorktreeID, + WorktreeBranchPrefix: r.WorktreeBranchPrefix, + PullBeforeWorktree: r.PullBeforeWorktree, + }} } // MessageAttachment represents an image or file attachment for agent prompts. diff --git a/apps/backend/internal/agent/lifecycle/types_repospecs_test.go b/apps/backend/internal/agent/lifecycle/types_repospecs_test.go new file mode 100644 index 0000000000..b9cb9d1edb --- /dev/null +++ b/apps/backend/internal/agent/lifecycle/types_repospecs_test.go @@ -0,0 +1,92 @@ +package lifecycle + +import "testing" + +func TestLaunchRequest_RepoSpecs_ReturnsExplicitListWhenSet(t *testing.T) { + req := &LaunchRequest{ + // Single-repo top-level fields are populated from Repositories[0] for + // backwards compat by callers, but RepoSpecs() must always prefer the + // explicit list when present. + RepositoryID: "legacy-repo", + RepositoryPath: "/legacy", + Repositories: []RepoLaunchSpec{ + {RepositoryID: "repo-a", RepositoryPath: "/a", BaseBranch: "main"}, + {RepositoryID: "repo-b", RepositoryPath: "/b", BaseBranch: "develop"}, + }, + } + specs := req.RepoSpecs() + if len(specs) != 2 { + t.Fatalf("expected 2 specs, got %d", len(specs)) + } + if specs[0].RepositoryID != "repo-a" || specs[1].RepositoryID != "repo-b" { + t.Errorf("unexpected order: %+v", specs) + } +} + +func TestLaunchRequest_RepoSpecs_SynthesizesFromLegacyFields(t *testing.T) { + req := &LaunchRequest{ + RepositoryID: "repo-x", + RepositoryPath: "/x", + BaseBranch: "main", + CheckoutBranch: "feature/y", + WorktreeID: "wt-1", + WorktreeBranchPrefix: "feat/", + PullBeforeWorktree: true, + RepoName: "x", + } + specs := req.RepoSpecs() + if len(specs) != 1 { + t.Fatalf("expected length-1 list synthesized, got %d", len(specs)) + } + got := specs[0] + if got.RepositoryID != "repo-x" || got.RepositoryPath != "/x" || + got.BaseBranch != "main" || got.CheckoutBranch != "feature/y" || + got.WorktreeID != "wt-1" || got.WorktreeBranchPrefix != "feat/" || + !got.PullBeforeWorktree || got.RepoName != "x" { + t.Errorf("synthesized spec mismatch: %+v", got) + } +} + +func TestLaunchRequest_RepoSpecs_NilForRepoLessLaunch(t *testing.T) { + req := &LaunchRequest{TaskID: "t1", SessionID: "s1"} + if specs := req.RepoSpecs(); specs != nil { + t.Errorf("expected nil for repo-less launch, got %v", specs) + } +} + +func TestEnvPrepareRequest_RepoSpecs_ExplicitWins(t *testing.T) { + req := &EnvPrepareRequest{ + RepositoryID: "legacy", + RepositoryPath: "/legacy", + Repositories: []RepoPrepareSpec{ + {RepositoryID: "a", RepositoryPath: "/a"}, + }, + } + specs := req.RepoSpecs() + if len(specs) != 1 || specs[0].RepositoryID != "a" { + t.Fatalf("expected explicit list to win; got %+v", specs) + } +} + +func TestEnvPrepareRequest_RepoSpecs_SynthesizedCarriesRepoSetupScript(t *testing.T) { + req := &EnvPrepareRequest{ + RepositoryID: "r1", + RepositoryPath: "/r1", + BaseBranch: "main", + RepoSetupScript: "make install", + } + specs := req.RepoSpecs() + if len(specs) != 1 { + t.Fatalf("expected 1 spec, got %d", len(specs)) + } + if specs[0].RepoSetupScript != "make install" { + t.Errorf("repo setup script not propagated: %+v", specs[0]) + } +} + +func TestEnvPrepareRequest_RepoSpecs_NilForRepoLess(t *testing.T) { + req := &EnvPrepareRequest{} + if specs := req.RepoSpecs(); specs != nil { + t.Errorf("expected nil, got %+v", specs) + } +} diff --git a/apps/backend/internal/agentctl/client/client_files.go b/apps/backend/internal/agentctl/client/client_files.go index 9efa5daa52..5846f881db 100644 --- a/apps/backend/internal/agentctl/client/client_files.go +++ b/apps/backend/internal/agentctl/client/client_files.go @@ -79,9 +79,13 @@ func (c *Client) SearchFiles(ctx context.Context, query string, limit int) (*Fil return &result, nil } -// RequestFileContent requests file content via HTTP GET -func (c *Client) RequestFileContent(ctx context.Context, path string) (*FileContentResponse, error) { +// RequestFileContent requests file content via HTTP GET. +// repo is the multi-repo subpath (e.g. "kandev"); empty for single-repo workspaces. +func (c *Client) RequestFileContent(ctx context.Context, path, repo string) (*FileContentResponse, error) { reqURL := fmt.Sprintf("%s/api/v1/workspace/file/content?path=%s", c.baseURL, url.QueryEscape(path)) + if repo != "" { + reqURL += "&repo=" + url.QueryEscape(repo) + } req, err := http.NewRequestWithContext(ctx, "GET", reqURL, nil) if err != nil { @@ -110,9 +114,13 @@ func (c *Client) RequestFileContent(ctx context.Context, path string) (*FileCont return &response, nil } -// RequestFileContentAtRef requests file content at a specific git ref via HTTP GET -func (c *Client) RequestFileContentAtRef(ctx context.Context, path string, ref string) (*FileContentResponse, error) { +// RequestFileContentAtRef requests file content at a specific git ref via HTTP GET. +// repo is the multi-repo subpath (e.g. "kandev"); empty for single-repo workspaces. +func (c *Client) RequestFileContentAtRef(ctx context.Context, path, ref, repo string) (*FileContentResponse, error) { reqURL := fmt.Sprintf("%s/api/v1/workspace/file/content-at-ref?path=%s&ref=%s", c.baseURL, url.QueryEscape(path), url.QueryEscape(ref)) + if repo != "" { + reqURL += "&repo=" + url.QueryEscape(repo) + } req, err := http.NewRequestWithContext(ctx, "GET", reqURL, nil) if err != nil { @@ -141,10 +149,12 @@ func (c *Client) RequestFileContentAtRef(ctx context.Context, path string, ref s return &response, nil } -// ApplyFileDiff applies a unified diff to a file via HTTP POST -func (c *Client) ApplyFileDiff(ctx context.Context, path, diff, originalHash string, desiredContent *string) (*streams.FileUpdateResponse, error) { +// ApplyFileDiff applies a unified diff to a file via HTTP POST. +// repo is the multi-repo subpath (e.g. "kandev"); empty for single-repo workspaces. +func (c *Client) ApplyFileDiff(ctx context.Context, path, diff, originalHash, repo string, desiredContent *string) (*streams.FileUpdateResponse, error) { reqBody := streams.FileUpdateRequest{ Path: path, + Repo: repo, Diff: diff, OriginalHash: originalHash, DesiredContent: desiredContent, @@ -188,9 +198,10 @@ func (c *Client) ApplyFileDiff(ctx context.Context, path, diff, originalHash str return &response, nil } -// CreateFile creates a new file via HTTP POST -func (c *Client) CreateFile(ctx context.Context, path string) (*streams.FileCreateResponse, error) { - reqBody := streams.FileCreateRequest{Path: path} +// CreateFile creates a new file via HTTP POST. +// repo is the multi-repo subpath (e.g. "kandev"); empty for single-repo workspaces. +func (c *Client) CreateFile(ctx context.Context, path, repo string) (*streams.FileCreateResponse, error) { + reqBody := streams.FileCreateRequest{Path: path, Repo: repo} bodyBytes, err := json.Marshal(reqBody) if err != nil { @@ -230,9 +241,13 @@ func (c *Client) CreateFile(ctx context.Context, path string) (*streams.FileCrea return &response, nil } -// DeleteFile deletes a file via HTTP DELETE -func (c *Client) DeleteFile(ctx context.Context, path string) (*streams.FileDeleteResponse, error) { +// DeleteFile deletes a file via HTTP DELETE. +// repo is the multi-repo subpath (e.g. "kandev"); empty for single-repo workspaces. +func (c *Client) DeleteFile(ctx context.Context, path, repo string) (*streams.FileDeleteResponse, error) { reqURL := fmt.Sprintf("%s/api/v1/workspace/file?path=%s", c.baseURL, url.QueryEscape(path)) + if repo != "" { + reqURL += "&repo=" + url.QueryEscape(repo) + } req, err := http.NewRequestWithContext(ctx, "DELETE", reqURL, nil) if err != nil { @@ -265,9 +280,10 @@ func (c *Client) DeleteFile(ctx context.Context, path string) (*streams.FileDele return &response, nil } -// RenameFile renames/moves a file or directory via HTTP POST -func (c *Client) RenameFile(ctx context.Context, oldPath, newPath string) (*streams.FileRenameResponse, error) { - reqBody := streams.FileRenameRequest{OldPath: oldPath, NewPath: newPath} +// RenameFile renames/moves a file or directory via HTTP POST. +// repo is the multi-repo subpath (e.g. "kandev"); empty for single-repo workspaces. +func (c *Client) RenameFile(ctx context.Context, oldPath, newPath, repo string) (*streams.FileRenameResponse, error) { + reqBody := streams.FileRenameRequest{OldPath: oldPath, NewPath: newPath, Repo: repo} bodyBytes, err := json.Marshal(reqBody) if err != nil { diff --git a/apps/backend/internal/agentctl/client/git.go b/apps/backend/internal/agentctl/client/git.go index 0cf7cf6cbf..e2cb5ea557 100644 --- a/apps/backend/internal/agentctl/client/git.go +++ b/apps/backend/internal/agentctl/client/git.go @@ -30,11 +30,14 @@ type PRCreateResult struct { // GitPull performs a git pull operation on the worktree. // If rebase is true, uses git pull --rebase. -func (c *Client) GitPull(ctx context.Context, rebase bool) (*GitOperationResult, error) { +// repo is the multi-repo subpath (e.g. "kandev"); empty for single-repo workspaces. +func (c *Client) GitPull(ctx context.Context, rebase bool, repo string) (*GitOperationResult, error) { payload := struct { - Rebase bool `json:"rebase"` + Rebase bool `json:"rebase"` + Repo string `json:"repo,omitempty"` }{ Rebase: rebase, + Repo: repo, } return c.gitOperation(ctx, "/api/v1/git/pull", payload) } @@ -42,46 +45,58 @@ func (c *Client) GitPull(ctx context.Context, rebase bool) (*GitOperationResult, // GitPush performs a git push operation on the worktree. // If force is true, uses --force-with-lease. // If setUpstream is true, uses --set-upstream. -func (c *Client) GitPush(ctx context.Context, force, setUpstream bool) (*GitOperationResult, error) { +// repo is the multi-repo subpath (e.g. "kandev"); empty for single-repo workspaces. +func (c *Client) GitPush(ctx context.Context, force, setUpstream bool, repo string) (*GitOperationResult, error) { payload := struct { - Force bool `json:"force"` - SetUpstream bool `json:"set_upstream"` + Force bool `json:"force"` + SetUpstream bool `json:"set_upstream"` + Repo string `json:"repo,omitempty"` }{ Force: force, SetUpstream: setUpstream, + Repo: repo, } return c.gitOperation(ctx, "/api/v1/git/push", payload) } // GitRebase rebases the worktree branch onto the specified base branch. // It first fetches origin/, then rebases onto it. -func (c *Client) GitRebase(ctx context.Context, baseBranch string) (*GitOperationResult, error) { +// repo is the multi-repo subpath (e.g. "kandev"); empty for single-repo workspaces. +func (c *Client) GitRebase(ctx context.Context, baseBranch, repo string) (*GitOperationResult, error) { payload := struct { BaseBranch string `json:"base_branch"` + Repo string `json:"repo,omitempty"` }{ BaseBranch: baseBranch, + Repo: repo, } return c.gitOperation(ctx, "/api/v1/git/rebase", payload) } // GitMerge merges the specified base branch into the worktree branch. // It first fetches origin/, then merges it. -func (c *Client) GitMerge(ctx context.Context, baseBranch string) (*GitOperationResult, error) { +// repo is the multi-repo subpath (e.g. "kandev"); empty for single-repo workspaces. +func (c *Client) GitMerge(ctx context.Context, baseBranch, repo string) (*GitOperationResult, error) { payload := struct { BaseBranch string `json:"base_branch"` + Repo string `json:"repo,omitempty"` }{ BaseBranch: baseBranch, + Repo: repo, } return c.gitOperation(ctx, "/api/v1/git/merge", payload) } // GitAbort aborts an in-progress merge or rebase operation. // The operation parameter must be "merge" or "rebase". -func (c *Client) GitAbort(ctx context.Context, operation string) (*GitOperationResult, error) { +// repo is the multi-repo subpath (e.g. "kandev"); empty for single-repo workspaces. +func (c *Client) GitAbort(ctx context.Context, operation, repo string) (*GitOperationResult, error) { payload := struct { Operation string `json:"operation"` + Repo string `json:"repo,omitempty"` }{ Operation: operation, + Repo: repo, } return c.gitOperation(ctx, "/api/v1/git/abort", payload) } @@ -89,97 +104,121 @@ func (c *Client) GitAbort(ctx context.Context, operation string) (*GitOperationR // GitCommit creates a commit with the specified message. // If stageAll is true, all changes are staged before committing. // If amend is true, it amends the previous commit instead of creating a new one. -func (c *Client) GitCommit(ctx context.Context, message string, stageAll bool, amend bool) (*GitOperationResult, error) { +// repo is the multi-repo subpath (e.g. "kandev"); empty for single-repo workspaces. +func (c *Client) GitCommit(ctx context.Context, message string, stageAll bool, amend bool, repo string) (*GitOperationResult, error) { payload := struct { Message string `json:"message"` StageAll bool `json:"stage_all"` Amend bool `json:"amend"` + Repo string `json:"repo,omitempty"` }{ Message: message, StageAll: stageAll, Amend: amend, + Repo: repo, } return c.gitOperation(ctx, "/api/v1/git/commit", payload) } // GitRenameBranch renames the current branch to a new name. -func (c *Client) GitRenameBranch(ctx context.Context, newName string) (*GitOperationResult, error) { +// repo is the multi-repo subpath (e.g. "kandev"); empty for single-repo workspaces. +func (c *Client) GitRenameBranch(ctx context.Context, newName, repo string) (*GitOperationResult, error) { payload := struct { NewName string `json:"new_name"` + Repo string `json:"repo,omitempty"` }{ NewName: newName, + Repo: repo, } return c.gitOperation(ctx, "/api/v1/git/rename-branch", payload) } // GitStage stages files for commit. // If paths is empty, stages all changes (git add -A). -func (c *Client) GitStage(ctx context.Context, paths []string) (*GitOperationResult, error) { +// repo is the multi-repo subpath (e.g. "kandev"); empty for single-repo workspaces. +func (c *Client) GitStage(ctx context.Context, paths []string, repo string) (*GitOperationResult, error) { payload := struct { Paths []string `json:"paths"` + Repo string `json:"repo,omitempty"` }{ Paths: paths, + Repo: repo, } return c.gitOperation(ctx, "/api/v1/git/stage", payload) } // GitUnstage unstages files from the index. // If paths is empty, unstages all changes (git reset HEAD). -func (c *Client) GitUnstage(ctx context.Context, paths []string) (*GitOperationResult, error) { +// repo is the multi-repo subpath (e.g. "kandev"); empty for single-repo workspaces. +func (c *Client) GitUnstage(ctx context.Context, paths []string, repo string) (*GitOperationResult, error) { payload := struct { Paths []string `json:"paths"` + Repo string `json:"repo,omitempty"` }{ Paths: paths, + Repo: repo, } return c.gitOperation(ctx, "/api/v1/git/unstage", payload) } // GitDiscard discards changes to files, reverting them to HEAD. // Paths must not be empty - at least one file must be specified. -func (c *Client) GitDiscard(ctx context.Context, paths []string) (*GitOperationResult, error) { +// repo is the multi-repo subpath (e.g. "kandev"); empty for single-repo workspaces. +func (c *Client) GitDiscard(ctx context.Context, paths []string, repo string) (*GitOperationResult, error) { payload := struct { Paths []string `json:"paths"` + Repo string `json:"repo,omitempty"` }{ Paths: paths, + Repo: repo, } return c.gitOperation(ctx, "/api/v1/git/discard", payload) } // GitRevertCommit undoes the latest commit using git reset --soft, keeping changes staged. -func (c *Client) GitRevertCommit(ctx context.Context, commitSHA string) (*GitOperationResult, error) { +// repo is the multi-repo subpath (e.g. "kandev"); empty for single-repo workspaces. +func (c *Client) GitRevertCommit(ctx context.Context, commitSHA, repo string) (*GitOperationResult, error) { payload := struct { CommitSHA string `json:"commit_sha"` + Repo string `json:"repo,omitempty"` }{ CommitSHA: commitSHA, + Repo: repo, } return c.gitOperation(ctx, "/api/v1/git/revert-commit", payload) } // GitReset resets HEAD to the specified commit. // Mode can be "soft" (keep changes staged), "mixed" (keep changes unstaged), or "hard" (discard all changes). -func (c *Client) GitReset(ctx context.Context, commitSHA, mode string) (*GitOperationResult, error) { +// repo is the multi-repo subpath (e.g. "kandev"); empty for single-repo workspaces. +func (c *Client) GitReset(ctx context.Context, commitSHA, mode, repo string) (*GitOperationResult, error) { payload := struct { CommitSHA string `json:"commit_sha"` Mode string `json:"mode"` + Repo string `json:"repo,omitempty"` }{ CommitSHA: commitSHA, Mode: mode, + Repo: repo, } return c.gitOperation(ctx, "/api/v1/git/reset", payload) } // GitCreatePR creates a pull request using the gh CLI. -func (c *Client) GitCreatePR(ctx context.Context, title, body, baseBranch string, draft bool) (*PRCreateResult, error) { +// repo is the multi-repo subpath (e.g. "kandev"); empty for single-repo workspaces. +func (c *Client) GitCreatePR(ctx context.Context, title, body, baseBranch string, draft bool, repo string) (*PRCreateResult, error) { payload := struct { Title string `json:"title"` Body string `json:"body"` BaseBranch string `json:"base_branch"` Draft bool `json:"draft"` + Repo string `json:"repo,omitempty"` }{ Title: title, Body: body, BaseBranch: baseBranch, Draft: draft, + Repo: repo, } reqBody, err := json.Marshal(payload) @@ -273,8 +312,15 @@ type CommitDiffResult struct { } // GitShowCommit gets the diff for a specific commit. -func (c *Client) GitShowCommit(ctx context.Context, commitSHA string) (*CommitDiffResult, error) { - req, err := http.NewRequestWithContext(ctx, "GET", c.baseURL+"/api/v1/git/commit/"+commitSHA, nil) +// repo is the multi-repo subpath (e.g. "kandev"); empty for single-repo workspaces. +// Multi-repo tasks must pass the owning repo because a SHA from one repo's +// commit graph isn't resolvable in any other repo. +func (c *Client) GitShowCommit(ctx context.Context, commitSHA, repo string) (*CommitDiffResult, error) { + reqURL := c.baseURL + "/api/v1/git/commit/" + commitSHA + if repo != "" { + reqURL += "?repo=" + url.QueryEscape(repo) + } + req, err := http.NewRequestWithContext(ctx, "GET", reqURL, nil) if err != nil { return nil, fmt.Errorf("failed to create request: %w", err) } @@ -308,6 +354,18 @@ type GitLogResult struct { Success bool `json:"success"` Commits []*GitCommitInfo `json:"commits"` Error string `json:"error,omitempty"` + // PerRepoErrors lists per-repo failures during a multi-repo log fan-out. + // Empty/nil for single-repo responses or when every repo succeeded. Mirrors + // the server's process.GitLogResult.PerRepoErrors field. + PerRepoErrors []GitLogRepoError `json:"per_repo_errors,omitempty"` +} + +// GitLogRepoError describes a single per-repo failure from a multi-repo log +// fan-out. Mirrors the server's process.GitLogRepoError type so callers using +// the agentctl client can deserialize and surface partial failures. +type GitLogRepoError struct { + RepositoryName string `json:"repository_name"` + Error string `json:"error"` } // GitCommitInfo represents a single commit in the log. @@ -322,17 +380,25 @@ type GitCommitInfo struct { FilesChanged int `json:"files_changed"` Insertions int `json:"insertions"` Deletions int `json:"deletions"` + // RepositoryName tags commits when fetched from a multi-repo subpath. Empty + // for single-repo workspaces. Stamped client-side after the per-repo call + // so callers can fan out across repos and merge. + RepositoryName string `json:"repository_name,omitempty"` } // GitLog gets the commit log from baseCommit to HEAD. // If baseCommit is empty, returns recent commits (limited by limit). // If targetBranch is provided, computes merge-base dynamically for accurate filtering. -func (c *Client) GitLog(ctx context.Context, baseCommit string, limit int, targetBranch string) (*GitLogResult, error) { +// repo is the multi-repo subpath (e.g. "kandev"); empty for single-repo workspaces. +func (c *Client) GitLog(ctx context.Context, baseCommit string, limit int, targetBranch, repo string) (*GitLogResult, error) { reqURL := fmt.Sprintf("%s/api/v1/git/log?since=%s&limit=%d", c.baseURL, url.QueryEscape(baseCommit), limit) if targetBranch != "" { reqURL += "&target_branch=" + url.QueryEscape(targetBranch) } + if repo != "" { + reqURL += "&repo=" + url.QueryEscape(repo) + } req, err := http.NewRequestWithContext(ctx, "GET", reqURL, nil) if err != nil { @@ -427,33 +493,73 @@ type GitStatusResult struct { Error string `json:"error,omitempty"` } -// GetGitStatus gets the current git status from the workspace. -func (c *Client) GetGitStatus(ctx context.Context) (*GitStatusResult, error) { - req, err := http.NewRequestWithContext(ctx, "GET", c.baseURL+"/api/v1/git/status", nil) +// fetchJSONResult performs a GET against `path` and decodes the response into +// `out`. Returns the HTTP status code so callers can decide whether to wrap +// any structured-error body into the typed result. Centralises the boilerplate +// shared between the status endpoints (which is what triggers `dupl`). +func (c *Client) fetchJSONResult(ctx context.Context, path string, out any) (int, error) { + req, err := http.NewRequestWithContext(ctx, "GET", c.baseURL+path, nil) if err != nil { - return nil, fmt.Errorf("failed to create request: %w", err) + return 0, fmt.Errorf("failed to create request: %w", err) } - resp, err := c.httpClient.Do(req) if err != nil { - return nil, fmt.Errorf("failed to execute request: %w", err) + return 0, fmt.Errorf("failed to execute request: %w", err) } defer func() { _ = resp.Body.Close() }() - respBody, err := readResponseBody(resp) if err != nil { - return nil, fmt.Errorf("failed to read response body: %w", err) + return resp.StatusCode, fmt.Errorf("failed to read response body: %w", err) } - - var result GitStatusResult - if err := json.Unmarshal(respBody, &result); err != nil { - return nil, fmt.Errorf("failed to parse response (status %d, body: %s): %w", + if err := json.Unmarshal(respBody, out); err != nil { + return resp.StatusCode, fmt.Errorf("failed to parse response (status %d, body: %s): %w", resp.StatusCode, truncateBody(respBody), err) } + return resp.StatusCode, nil +} - if resp.StatusCode >= 400 { - return &result, fmt.Errorf("git status failed with status %d: %s", resp.StatusCode, result.Error) +// GetGitStatus gets the current git status from the workspace. +func (c *Client) GetGitStatus(ctx context.Context) (*GitStatusResult, error) { + var result GitStatusResult + status, err := c.fetchJSONResult(ctx, "/api/v1/git/status", &result) + if err != nil { + return nil, err + } + if status >= 400 { + return &result, fmt.Errorf("git status failed with status %d: %s", status, result.Error) + } + return &result, nil +} + +// PerRepoGitStatus pairs a repository_name with its status. Mirrors the +// server-side shape so the gateway can fan a single subscribe out into one +// notification per repo (multi-repo workspaces) without re-running git +// commands once per call. +type PerRepoGitStatus struct { + RepositoryName string `json:"repository_name"` + Status GitStatusResult `json:"status"` +} + +// MultiRepoGitStatusResult is the response shape for /api/v1/git/status/multi. +type MultiRepoGitStatusResult struct { + Success bool `json:"success"` + Repos []PerRepoGitStatus `json:"repos"` + Error string `json:"error,omitempty"` +} + +// GetGitStatusMulti returns one status entry per repo (multi-repo) or a +// single untagged entry (single-repo). Used by the session-subscribe handler +// in the main backend to seed the frontend's per-repo state on page reload — +// without it, the frontend never sees per-repo grouping until something else +// (a file change, a poll) pushes a stamped notification. +func (c *Client) GetGitStatusMulti(ctx context.Context) (*MultiRepoGitStatusResult, error) { + var result MultiRepoGitStatusResult + status, err := c.fetchJSONResult(ctx, "/api/v1/git/status/multi", &result) + if err != nil { + return nil, err + } + if status >= 400 { + return &result, fmt.Errorf("git status multi failed with status %d: %s", status, result.Error) } - return &result, nil } diff --git a/apps/backend/internal/agentctl/server/api/git.go b/apps/backend/internal/agentctl/server/api/git.go index b422746780..888962e94b 100644 --- a/apps/backend/internal/agentctl/server/api/git.go +++ b/apps/backend/internal/agentctl/server/api/git.go @@ -4,36 +4,63 @@ import ( "errors" "fmt" "net/http" + "sort" + "time" "github.com/gin-gonic/gin" "github.com/kandev/kandev/internal/agentctl/server/process" "go.uber.org/zap" ) +// sortCommitsByCommittedAtDesc sorts commits newest-first using committed_at. +// Commits with unparseable timestamps preserve their original relative order +// (sort.SliceStable). Used when merging logs from multiple repos so the +// combined timeline reads chronologically. +func sortCommitsByCommittedAtDesc(commits []*process.GitCommitInfo) { + sort.SliceStable(commits, func(i, j int) bool { + ti, errI := time.Parse(time.RFC3339, commits[i].CommittedAt) + tj, errJ := time.Parse(time.RFC3339, commits[j].CommittedAt) + if errI != nil || errJ != nil { + return false + } + return ti.After(tj) + }) +} + +// Repo selects a repository sub-directory of the workspace for multi-repo +// task roots. Optional. Empty = workspace root (single-repo behavior). +// All git request structs below embed this field via the standard `repo` JSON +// key (or `repo` form key for GET endpoints). + // GitPullRequest for POST /api/v1/git/pull type GitPullRequest struct { - Rebase bool `json:"rebase"` + Rebase bool `json:"rebase"` + Repo string `json:"repo,omitempty"` } // GitPushRequest for POST /api/v1/git/push type GitPushRequest struct { - Force bool `json:"force"` - SetUpstream bool `json:"set_upstream"` + Force bool `json:"force"` + SetUpstream bool `json:"set_upstream"` + Repo string `json:"repo,omitempty"` } // GitRebaseRequest for POST /api/v1/git/rebase type GitRebaseRequest struct { BaseBranch string `json:"base_branch"` + Repo string `json:"repo,omitempty"` } // GitMergeRequest for POST /api/v1/git/merge type GitMergeRequest struct { BaseBranch string `json:"base_branch"` + Repo string `json:"repo,omitempty"` } // GitAbortRequest for POST /api/v1/git/abort type GitAbortRequest struct { Operation string `json:"operation"` // "merge" or "rebase" + Repo string `json:"repo,omitempty"` } // GitCommitRequest for POST /api/v1/git/commit @@ -41,26 +68,31 @@ type GitCommitRequest struct { Message string `json:"message"` StageAll bool `json:"stage_all"` Amend bool `json:"amend"` + Repo string `json:"repo,omitempty"` } // GitRenameBranchRequest for POST /api/v1/git/rename-branch type GitRenameBranchRequest struct { NewName string `json:"new_name"` + Repo string `json:"repo,omitempty"` } // GitStageRequest for POST /api/v1/git/stage type GitStageRequest struct { Paths []string `json:"paths"` // Empty = stage all + Repo string `json:"repo,omitempty"` } // GitUnstageRequest for POST /api/v1/git/unstage type GitUnstageRequest struct { Paths []string `json:"paths"` // Empty = unstage all + Repo string `json:"repo,omitempty"` } // GitDiscardRequest for POST /api/v1/git/discard type GitDiscardRequest struct { Paths []string `json:"paths"` // Required - files to discard + Repo string `json:"repo,omitempty"` } // GitShowCommitRequest for GET /api/v1/git/commit/:sha @@ -71,6 +103,7 @@ type GitShowCommitRequest struct { // GitRevertCommitRequest for POST /api/v1/git/revert-commit type GitRevertCommitRequest struct { CommitSHA string `json:"commit_sha"` + Repo string `json:"repo,omitempty"` } // GitCreatePRRequest for POST /api/v1/git/create-pr @@ -79,12 +112,14 @@ type GitCreatePRRequest struct { Body string `json:"body"` BaseBranch string `json:"base_branch"` Draft bool `json:"draft"` + Repo string `json:"repo,omitempty"` } // GitResetRequest for POST /api/v1/git/reset type GitResetRequest struct { CommitSHA string `json:"commit_sha"` Mode string `json:"mode"` // "soft", "mixed", or "hard" + Repo string `json:"repo,omitempty"` } // GitLogRequest for GET /api/v1/git/log @@ -92,11 +127,29 @@ type GitLogRequest struct { Since string `form:"since"` // Base commit SHA (exclusive) TargetBranch string `form:"target_branch"` // Target branch for merge-base calculation (e.g., "origin/main") Limit int `form:"limit"` // Max commits to return + Repo string `form:"repo"` } // GitCumulativeDiffRequest for GET /api/v1/git/cumulative-diff type GitCumulativeDiffRequest struct { Base string `form:"base" binding:"required"` // Base commit SHA + Repo string `form:"repo"` +} + +// gitOpForRepo resolves the optional repo subpath to a per-repo GitOperator. +// On invalid subpath it writes a 400 response and returns nil so callers can +// `return` immediately. Empty subpath returns the workspace-root operator. +func (s *Server) gitOpForRepo(c *gin.Context, operation, subpath string) *process.GitOperator { + op, err := s.procMgr.GitOperatorFor(subpath) + if err != nil { + c.JSON(http.StatusBadRequest, process.GitOperationResult{ + Success: false, + Operation: operation, + Error: err.Error(), + }) + return nil + } + return op } // handleGitPull handles POST /api/v1/git/pull @@ -111,7 +164,10 @@ func (s *Server) handleGitPull(c *gin.Context) { return } - gitOp := s.procMgr.GitOperator() + gitOp := s.gitOpForRepo(c, "pull", req.Repo) + if gitOp == nil { + return + } result, err := gitOp.Pull(c.Request.Context(), req.Rebase) if err != nil { s.handleGitError(c, "pull", err) @@ -133,7 +189,10 @@ func (s *Server) handleGitPush(c *gin.Context) { return } - gitOp := s.procMgr.GitOperator() + gitOp := s.gitOpForRepo(c, "push", req.Repo) + if gitOp == nil { + return + } result, err := gitOp.Push(c.Request.Context(), req.Force, req.SetUpstream) if err != nil { s.handleGitError(c, "push", err) @@ -164,7 +223,10 @@ func (s *Server) handleGitRebase(c *gin.Context) { return } - gitOp := s.procMgr.GitOperator() + gitOp := s.gitOpForRepo(c, "rebase", req.Repo) + if gitOp == nil { + return + } result, err := gitOp.Rebase(c.Request.Context(), req.BaseBranch) if err != nil { s.handleGitError(c, "rebase", err) @@ -195,7 +257,10 @@ func (s *Server) handleGitMerge(c *gin.Context) { return } - gitOp := s.procMgr.GitOperator() + gitOp := s.gitOpForRepo(c, "merge", req.Repo) + if gitOp == nil { + return + } result, err := gitOp.Merge(c.Request.Context(), req.BaseBranch) if err != nil { s.handleGitError(c, "merge", err) @@ -226,7 +291,10 @@ func (s *Server) handleGitAbort(c *gin.Context) { return } - gitOp := s.procMgr.GitOperator() + gitOp := s.gitOpForRepo(c, "abort", req.Repo) + if gitOp == nil { + return + } result, err := gitOp.Abort(c.Request.Context(), req.Operation) if err != nil { s.handleGitError(c, "abort", err) @@ -257,7 +325,10 @@ func (s *Server) handleGitCommit(c *gin.Context) { return } - gitOp := s.procMgr.GitOperator() + gitOp := s.gitOpForRepo(c, "commit", req.Repo) + if gitOp == nil { + return + } result, err := gitOp.Commit(c.Request.Context(), req.Message, req.StageAll, req.Amend) if err != nil { s.handleGitError(c, "commit", err) @@ -288,7 +359,10 @@ func (s *Server) handleGitRenameBranch(c *gin.Context) { return } - gitOp := s.procMgr.GitOperator() + gitOp := s.gitOpForRepo(c, "rename_branch", req.Repo) + if gitOp == nil { + return + } result, err := gitOp.RenameBranch(c.Request.Context(), req.NewName) if err != nil { s.handleGitError(c, "rename_branch", err) @@ -310,7 +384,10 @@ func (s *Server) handleGitStage(c *gin.Context) { return } - gitOp := s.procMgr.GitOperator() + gitOp := s.gitOpForRepo(c, "stage", req.Repo) + if gitOp == nil { + return + } result, err := gitOp.Stage(c.Request.Context(), req.Paths) if err != nil { s.handleGitError(c, "stage", err) @@ -332,7 +409,10 @@ func (s *Server) handleGitUnstage(c *gin.Context) { return } - gitOp := s.procMgr.GitOperator() + gitOp := s.gitOpForRepo(c, "unstage", req.Repo) + if gitOp == nil { + return + } result, err := gitOp.Unstage(c.Request.Context(), req.Paths) if err != nil { s.handleGitError(c, "unstage", err) @@ -363,7 +443,10 @@ func (s *Server) handleGitDiscard(c *gin.Context) { return } - gitOp := s.procMgr.GitOperator() + gitOp := s.gitOpForRepo(c, "discard", req.Repo) + if gitOp == nil { + return + } result, err := gitOp.Discard(c.Request.Context(), req.Paths) if err != nil { s.handleGitError(c, "discard", err) @@ -392,7 +475,14 @@ func (s *Server) handleGitCreatePR(c *gin.Context) { return } - gitOp := s.procMgr.GitOperator() + gitOp, gitOpErr := s.procMgr.GitOperatorFor(req.Repo) + if gitOpErr != nil { + c.JSON(http.StatusBadRequest, process.PRCreateResult{ + Success: false, + Error: gitOpErr.Error(), + }) + return + } result, err := gitOp.CreatePR(c.Request.Context(), req.Title, req.Body, req.BaseBranch, req.Draft) if err != nil { if errors.Is(err, process.ErrOperationInProgress) { @@ -434,7 +524,10 @@ func (s *Server) handleGitRevertCommit(c *gin.Context) { return } - gitOp := s.procMgr.GitOperator() + gitOp := s.gitOpForRepo(c, "revert_commit", req.Repo) + if gitOp == nil { + return + } result, err := gitOp.RevertCommit(c.Request.Context(), req.CommitSHA) if err != nil { s.handleGitError(c, "revert_commit", err) @@ -455,7 +548,16 @@ func (s *Server) handleGitShowCommit(c *gin.Context) { return } - gitOp := s.procMgr.GitOperator() + // ShowCommit accepts an optional ?repo= query param. + gitOp, gitOpErr := s.procMgr.GitOperatorFor(c.Query("repo")) + if gitOpErr != nil { + c.JSON(http.StatusBadRequest, process.CommitDiffResult{ + Success: false, + CommitSHA: req.CommitSHA, + Error: gitOpErr.Error(), + }) + return + } result, err := gitOp.ShowCommit(c.Request.Context(), req.CommitSHA) if err != nil { s.logger.Error("git show commit failed", zap.String("commit_sha", req.CommitSHA), zap.Error(err)) @@ -504,7 +606,10 @@ func (s *Server) handleGitReset(c *gin.Context) { return } - gitOp := s.procMgr.GitOperator() + gitOp := s.gitOpForRepo(c, "reset", req.Repo) + if gitOp == nil { + return + } result, err := gitOp.Reset(c.Request.Context(), req.CommitSHA, req.Mode) if err != nil { s.handleGitError(c, "reset", err) @@ -514,7 +619,13 @@ func (s *Server) handleGitReset(c *gin.Context) { c.JSON(http.StatusOK, result) } -// handleGitLog handles GET /api/v1/git/log +// handleGitLog handles GET /api/v1/git/log. +// +// Multi-repo: when the caller doesn't pin a repo (?repo= empty) and the +// workspace contains per-repo subdirs, fan out one log per repo and merge +// the results, stamping RepositoryName on each commit so the frontend can +// group them. Without this, the call would land on the workspace root which +// for multi-repo task workspaces isn't a git repo and returns nothing. func (s *Server) handleGitLog(c *gin.Context) { var req GitLogRequest if err := c.ShouldBindQuery(&req); err != nil { @@ -541,38 +652,182 @@ func (s *Server) handleGitLog(c *gin.Context) { return } - gitOp := s.procMgr.GitOperator() + if req.Repo == "" { + if subs := s.procMgr.RepoSubpaths(); len(subs) > 0 { + s.handleGitLogMultiRepo(c, req, subs, limit) + return + } + } + + result, err := s.runGitLogForRepo(c, req, limit, req.Repo) + if err != nil { + s.handleGitError(c, "log", err) + return + } + c.JSON(http.StatusOK, result) +} + +// runGitLogForRepo runs git log against a single repo subpath. Returns a +// result-with-error or a non-nil error for transport failures. +func (s *Server) runGitLogForRepo( + c *gin.Context, + req GitLogRequest, + limit int, + repo string, +) (*process.GitLogResult, error) { + gitOp, gitOpErr := s.procMgr.GitOperatorFor(repo) + if gitOpErr != nil { + return nil, gitOpErr + } - // If target_branch is provided, compute merge-base dynamically. - // This ensures we only show commits not yet merged into the target branch, - // which is accurate even after rebases. baseCommit := req.Since if req.TargetBranch != "" { mergeBase, err := gitOp.GetMergeBase(c.Request.Context(), "HEAD", req.TargetBranch) if err != nil { s.logger.Warn("failed to compute merge-base, falling back to since", zap.String("target_branch", req.TargetBranch), + zap.String("repo", repo), zap.Error(err)) - // Fall back to the provided base commit } else if mergeBase != "" { baseCommit = mergeBase } } - result, err := gitOp.GetLog(c.Request.Context(), baseCommit, limit) - if err != nil { - s.logger.Error("git log failed", zap.Error(err)) - c.JSON(http.StatusInternalServerError, process.GitLogResult{ - Success: false, - Error: err.Error(), - }) - return - } - - c.JSON(http.StatusOK, result) + return gitOp.GetLog(c.Request.Context(), baseCommit, limit) } -// handleGitCumulativeDiff handles GET /api/v1/git/cumulative-diff +// perRepoLogOutcome captures the outcome of running git log against a single +// repo subpath. Exactly one of result/err is set; result.Success may still be +// false if the per-repo command itself reported a failure. +type perRepoLogOutcome struct { + subpath string + result *process.GitLogResult + err error +} + +// mergeGitLogResults combines per-repo log outcomes into a single response +// suitable for the multi-repo log endpoint. It is extracted from +// handleGitLogMultiRepo so the merge/error-aggregation logic is unit-testable +// without spinning up a real Manager. +// +// Behavior: +// - Successful repos contribute their commits (each tagged with RepositoryName). +// - Failed repos (transport error or result.Success=false) contribute an +// entry to PerRepoErrors so the frontend can render an "incomplete" warning. +// - Success=true iff at least one repo succeeded. If every repo failed, +// Success=false with a summary in Error. +// - Commits are sorted newest-first across repos and truncated to limit. +func mergeGitLogResults(outcomes []perRepoLogOutcome, limit int) process.GitLogResult { + merged := make([]*process.GitCommitInfo, 0) + perRepoErrors := make([]process.GitLogRepoError, 0) + for _, o := range outcomes { + if o.err != nil { + perRepoErrors = append(perRepoErrors, process.GitLogRepoError{ + RepositoryName: o.subpath, + Error: o.err.Error(), + }) + continue + } + if o.result == nil || !o.result.Success { + errMsg := "" + if o.result != nil { + errMsg = o.result.Error + } + perRepoErrors = append(perRepoErrors, process.GitLogRepoError{ + RepositoryName: o.subpath, + Error: errMsg, + }) + continue + } + for _, commit := range o.result.Commits { + commit.RepositoryName = o.subpath + merged = append(merged, commit) + } + } + + // Sort newest-first across repos. Falls back to insertion order if either + // timestamp is unparseable. + sortCommitsByCommittedAtDesc(merged) + if limit > 0 && len(merged) > limit { + merged = merged[:limit] + } + + resp := process.GitLogResult{Commits: merged} + if len(perRepoErrors) > 0 { + resp.PerRepoErrors = perRepoErrors + } + // Success iff at least one repo succeeded. When every repo failed we mark + // the response failed and put a one-line summary in Error so callers that + // only check Success/Error keep working without inspecting PerRepoErrors. + switch { + case len(outcomes) > 0 && len(perRepoErrors) == len(outcomes): + resp.Success = false + resp.Error = fmt.Sprintf("git log failed for all %d repositories", len(outcomes)) + default: + resp.Success = true + } + return resp +} + +// handleGitLogMultiRepo fans the request out across every per-repo tracker, +// stamps RepositoryName on each returned commit, and merges into a single +// log sorted by committed_at descending. Per-repo limits are applied +// individually; the merged result is then truncated to the request limit. +// +// Per-repo failures are surfaced via PerRepoErrors rather than silently +// dropped, so the frontend can render an "incomplete" warning instead of +// pretending the missing repo simply has no history. The merged response +// stays Success=true as long as at least one repo succeeded; if every repo +// failed, Success=false with a summary in Error. +func (s *Server) handleGitLogMultiRepo( + c *gin.Context, + req GitLogRequest, + subpaths []string, + limit int, +) { + outcomes := make([]perRepoLogOutcome, 0, len(subpaths)) + for _, sub := range subpaths { + // Multi-repo: the caller-supplied `since` is a SHA that only exists in + // the primary repo, and `target_branch` is the primary repo's base + // branch (e.g. "main"). Both can be wrong for sibling repos — running + // `git log ..HEAD` in lvc fails outright and the repo's + // commits silently disappear from the merged response. Compute a + // per-repo base via merge-base against `origin/main` (with + // `origin/master` fallback) so each repo's commits show up. + perRepoReq := req + perRepoReq.Since = "" + perRepoReq.TargetBranch = "" + if base := s.resolvePerRepoBase(c, sub); base != "" { + perRepoReq.Since = base + } + result, err := s.runGitLogForRepo(c, perRepoReq, limit, sub) + if err != nil { + s.logger.Warn("git log for repo failed", + zap.String("repo", sub), zap.Error(err)) + } else if !result.Success { + s.logger.Warn("git log for repo returned failure", + zap.String("repo", sub), zap.String("error", result.Error)) + } + outcomes = append(outcomes, perRepoLogOutcome{ + subpath: sub, + result: result, + err: err, + }) + } + + c.JSON(http.StatusOK, mergeGitLogResults(outcomes, limit)) +} + +// handleGitCumulativeDiff handles GET /api/v1/git/cumulative-diff. +// +// Multi-repo: when the caller doesn't pin a repo (?repo= empty) and the +// workspace contains per-repo subdirs, fan out one cumulative diff per repo +// and merge results, prefixing each file key with the repo subpath and +// stamping repository_name onto each file's payload. Without this the call +// would land on the workspace root which for multi-repo task workspaces +// isn't a git repo — and `git diff` would silently ascend to whatever .git +// encloses the task root (e.g. the user's outer kandev checkout) and return +// hundreds of unrelated files. func (s *Server) handleGitCumulativeDiff(c *gin.Context) { var req GitCumulativeDiffRequest if err := c.ShouldBindQuery(&req); err != nil { @@ -583,18 +838,142 @@ func (s *Server) handleGitCumulativeDiff(c *gin.Context) { return } - gitOp := s.procMgr.GitOperator() - result, err := gitOp.GetCumulativeDiff(c.Request.Context(), req.Base) + if req.Repo == "" { + if subs := s.procMgr.RepoSubpaths(); len(subs) > 0 { + s.handleGitCumulativeDiffMultiRepo(c, req, subs) + return + } + } + + result := s.runGitCumulativeDiffForRepo(c, req.Base, req.Repo) + if result == nil { + return // gitOp lookup error already wrote the response + } + c.JSON(http.StatusOK, result) +} + +// runGitCumulativeDiffForRepo runs cumulative diff against a single repo +// subpath. Returns nil after writing an error response on lookup failures; +// otherwise returns the (possibly success=false) result for the caller. +func (s *Server) runGitCumulativeDiffForRepo( + c *gin.Context, + base, repo string, +) *process.CumulativeDiffResult { + gitOp, gitOpErr := s.procMgr.GitOperatorFor(repo) + if gitOpErr != nil { + c.JSON(http.StatusBadRequest, process.CumulativeDiffResult{ + Success: false, + Error: gitOpErr.Error(), + }) + return nil + } + result, err := gitOp.GetCumulativeDiff(c.Request.Context(), base) if err != nil { - s.logger.Error("git cumulative diff failed", zap.String("base", req.Base), zap.Error(err)) + s.logger.Error("git cumulative diff failed", + zap.String("base", base), + zap.String("repo", repo), + zap.Error(err)) c.JSON(http.StatusInternalServerError, process.CumulativeDiffResult{ Success: false, Error: err.Error(), }) - return + return nil } + return result +} - c.JSON(http.StatusOK, result) +// handleGitCumulativeDiffMultiRepo fans cumulative diff out across every +// per-repo tracker. Each repo's base commit is computed locally via +// merge-base against the integration branch — the caller-supplied base only +// makes sense for one repo at a time, since each repo has its own commit +// graph. Files from each repo are merged into a single map, keyed by +// `/` so paths that exist in multiple repos don't clash, +// and tagged with `repository_name` so the frontend can group them. +func (s *Server) handleGitCumulativeDiffMultiRepo( + c *gin.Context, + req GitCumulativeDiffRequest, + subpaths []string, +) { + merged := process.CumulativeDiffResult{ + Files: make(map[string]interface{}), + Success: true, + } + anyOK := false + for _, sub := range subpaths { + base := s.resolvePerRepoBase(c, sub) + if base == "" { + s.logger.Warn("cumulative diff: no per-repo base, skipping", + zap.String("repo", sub)) + continue + } + result := s.runGitCumulativeDiffForRepo(c, base, sub) + if result == nil { + return // response already written + } + if !result.Success { + s.logger.Warn("cumulative diff for repo returned failure", + zap.String("repo", sub), + zap.String("error", result.Error)) + continue + } + anyOK = true + merged.TotalCommits += result.TotalCommits + mergeCumulativeFiles(merged.Files, result.Files, sub) + } + if !anyOK { + merged.Success = false + merged.Error = fmt.Sprintf("cumulative diff failed for all %d repositories", len(subpaths)) + } + c.JSON(http.StatusOK, merged) +} + +// resolvePerRepoBase returns the merge-base of HEAD against the integration +// branch (origin/main, with origin/master fallback). Multi-repo tasks share +// no single base commit across repos, so each repo computes its own. +func (s *Server) resolvePerRepoBase(c *gin.Context, repo string) string { + gitOp, err := s.procMgr.GitOperatorFor(repo) + if err != nil { + return "" + } + for _, candidate := range []string{"origin/main", "origin/master"} { + base, mbErr := gitOp.GetMergeBase(c.Request.Context(), "HEAD", candidate) + if mbErr == nil && base != "" { + return base + } + } + return "" +} + +// mergeCumulativeFiles copies per-repo files into the merged map under a +// ` ` key (NUL-separated) and decorates each file payload +// with `repository_name` + a `path` field carrying the repo-relative path. +// The composite key keeps `README.md` in two repos from clashing in the map; +// the frontend reads `path` and `repository_name` off the payload so the +// file tree groups under the repo header without the prefix bleeding into +// the displayed path. NUL is impossible in real paths, so the key is +// always uniquely splittable and the displayed path is unaffected. +func mergeCumulativeFiles(dst, src map[string]interface{}, repo string) { + for path, payload := range src { + m, ok := payload.(map[string]interface{}) + if !ok { + // Defensive: only known shape from parseCommitDiff is map[string]interface{}. + // If a future change emits something else, route it through unchanged + // rather than silently dropping the path/repo metadata. + dst[fmt.Sprintf("%s\x00%s", repo, path)] = payload + continue + } + // Shallow-copy the per-file map before stamping repository_name + + // path so the caller's source map isn't mutated. Earlier code wrote + // directly to `m`, which permanently rewrote the per-repo result + // before it could be reused (e.g. emitted to a second consumer). + copied := make(map[string]interface{}, len(m)+2) + for k, v := range m { + copied[k] = v + } + copied["repository_name"] = repo + copied["path"] = path + dst[fmt.Sprintf("%s\x00%s", repo, path)] = copied + } } // GitStatusResult represents the result of a git status query. @@ -618,9 +997,111 @@ type GitStatusResult struct { Error string `json:"error,omitempty"` } -// handleGitStatus handles GET /api/v1/git/status +// PerRepoGitStatus pairs a repository_name with its current status. Used by +// the multi-repo status fan-out so callers (notably the gateway's session +// subscribe handler) can deliver one stamped GitStatusUpdate per repo on +// reconnect — without it the frontend would only see the workspace-root +// (untagged) status and miss the per-repo grouping. +type PerRepoGitStatus struct { + RepositoryName string `json:"repository_name"` + Status GitStatusResult `json:"status"` +} + +// MultiRepoGitStatusResult is the response shape for /api/v1/git/status/multi. +// For multi-repo task workspaces the response carries one entry per repo, +// stamped with its name. Single-repo workspaces return a single entry with an +// empty repository_name so callers can treat both shapes uniformly. +type MultiRepoGitStatusResult struct { + Success bool `json:"success"` + Repos []PerRepoGitStatus `json:"repos"` + Error string `json:"error,omitempty"` +} + +// handleGitStatusMulti returns one git status entry per repo for multi-repo +// task workspaces (or one untagged entry for single-repo). Used by the +// session-subscribe handler in the main backend to seed per-repo state on +// page reload — the legacy single GET /api/v1/git/status endpoint returns +// only the workspace-root status, which is empty for multi-repo task roots +// (the root isn't itself a git repo). +func (s *Server) handleGitStatusMulti(c *gin.Context) { + subpaths := s.procMgr.RepoSubpaths() + // Single-repo: fall back to the workspace-root status with an empty repo + // name so the response shape stays uniform. + if len(subpaths) == 0 { + subpaths = []string{""} + } + result := MultiRepoGitStatusResult{Success: true, Repos: make([]PerRepoGitStatus, 0, len(subpaths))} + for _, sub := range subpaths { + entry := s.collectStatusForRepo(c, sub) + result.Repos = append(result.Repos, entry) + } + c.JSON(http.StatusOK, result) +} + +// collectStatusForRepo runs the status query for a single subpath and packs +// it into a PerRepoGitStatus. Failures land in Status.Error / Status.Success +// so the caller can render partial results instead of erroring out the whole +// fan-out when one repo is misconfigured. +func (s *Server) collectStatusForRepo(c *gin.Context, sub string) PerRepoGitStatus { + wt, wtErr := s.procMgr.GetWorkspaceTrackerFor(sub) + if wtErr != nil { + return PerRepoGitStatus{ + RepositoryName: sub, + Status: GitStatusResult{Success: false, Error: wtErr.Error()}, + } + } + if wt == nil { + return PerRepoGitStatus{ + RepositoryName: sub, + Status: GitStatusResult{Success: false, Error: "workspace tracker not available"}, + } + } + status, err := wt.GetCurrentGitStatus(c.Request.Context()) + if err != nil { + return PerRepoGitStatus{ + RepositoryName: sub, + Status: GitStatusResult{Success: false, Error: err.Error()}, + } + } + filesMap := make(map[string]interface{}, len(status.Files)) + for k, v := range status.Files { + filesMap[k] = v + } + return PerRepoGitStatus{ + RepositoryName: sub, + Status: GitStatusResult{ + Success: true, + Branch: status.Branch, + RemoteBranch: status.RemoteBranch, + HeadCommit: status.HeadCommit, + BaseCommit: status.BaseCommit, + Ahead: status.Ahead, + Behind: status.Behind, + Modified: status.Modified, + Added: status.Added, + Deleted: status.Deleted, + Untracked: status.Untracked, + Renamed: status.Renamed, + Files: filesMap, + Timestamp: status.Timestamp.Format("2006-01-02T15:04:05.000Z07:00"), + BranchAdditions: status.BranchAdditions, + BranchDeletions: status.BranchDeletions, + }, + } +} + +// handleGitStatus handles GET /api/v1/git/status. Accepts an optional +// ?repo= query param that selects a sub-directory of the workspace +// for multi-repo task roots; empty = workspace root (single-repo behavior). func (s *Server) handleGitStatus(c *gin.Context) { - wt := s.procMgr.GetWorkspaceTracker() + wt, wtErr := s.procMgr.GetWorkspaceTrackerFor(c.Query("repo")) + if wtErr != nil { + c.JSON(http.StatusBadRequest, GitStatusResult{ + Success: false, + Error: wtErr.Error(), + }) + return + } if wt == nil { c.JSON(http.StatusInternalServerError, GitStatusResult{ Success: false, diff --git a/apps/backend/internal/agentctl/server/api/git_log_multi_repo_test.go b/apps/backend/internal/agentctl/server/api/git_log_multi_repo_test.go new file mode 100644 index 0000000000..9d3a819326 --- /dev/null +++ b/apps/backend/internal/agentctl/server/api/git_log_multi_repo_test.go @@ -0,0 +1,195 @@ +package api + +import ( + "errors" + "testing" + + "github.com/kandev/kandev/internal/agentctl/server/process" +) + +// sortCommitsByCommittedAtDesc must order commits newest-first so the merged +// per-repo log reads chronologically, regardless of which repo bucket each +// commit came from. Unparseable timestamps preserve their relative order via +// SliceStable so we don't randomly reshuffle bad data. +func TestSortCommitsByCommittedAtDesc(t *testing.T) { + t.Parallel() + + t.Run("interleaves commits from multiple repos by timestamp", func(t *testing.T) { + commits := []*process.GitCommitInfo{ + {CommitSHA: "a", CommittedAt: "2026-04-26T10:00:00Z", RepositoryName: "frontend"}, + {CommitSHA: "b", CommittedAt: "2026-04-26T12:00:00Z", RepositoryName: "backend"}, + {CommitSHA: "c", CommittedAt: "2026-04-26T11:00:00Z", RepositoryName: "frontend"}, + } + sortCommitsByCommittedAtDesc(commits) + got := []string{commits[0].CommitSHA, commits[1].CommitSHA, commits[2].CommitSHA} + want := []string{"b", "c", "a"} + for i := range got { + if got[i] != want[i] { + t.Errorf("position %d: got %q, want %q (full order: %v)", i, got[i], want[i], got) + } + } + }) + + t.Run("preserves order on unparseable timestamps", func(t *testing.T) { + commits := []*process.GitCommitInfo{ + {CommitSHA: "x", CommittedAt: "not-a-date"}, + {CommitSHA: "y", CommittedAt: "also-bad"}, + } + sortCommitsByCommittedAtDesc(commits) + if commits[0].CommitSHA != "x" || commits[1].CommitSHA != "y" { + t.Errorf("expected stable order on bad timestamps; got %s, %s", + commits[0].CommitSHA, commits[1].CommitSHA) + } + }) +} + +// TestMergeGitLogResults verifies that mergeGitLogResults aggregates per-repo +// outcomes into the right shape: commits stamped with RepositoryName, partial +// failures surfaced via PerRepoErrors, and Success/Error reflecting whether +// the whole fan-out failed. +func TestMergeGitLogResults(t *testing.T) { + t.Parallel() + + t.Run("partial failure: one repo succeeds, one fails", func(t *testing.T) { + outcomes := []perRepoLogOutcome{ + { + subpath: "frontend", + result: &process.GitLogResult{ + Success: true, + Commits: []*process.GitCommitInfo{ + {CommitSHA: "a", CommittedAt: "2026-04-26T10:00:00Z"}, + }, + }, + }, + { + subpath: "backend", + err: errors.New("repo not found"), + }, + } + got := mergeGitLogResults(outcomes, 100) + if !got.Success { + t.Errorf("expected Success=true when at least one repo succeeded; got false (Error=%q)", got.Error) + } + if got.Error != "" { + t.Errorf("expected empty Error on partial success; got %q", got.Error) + } + if len(got.PerRepoErrors) != 1 { + t.Fatalf("expected 1 per-repo error; got %d", len(got.PerRepoErrors)) + } + if got.PerRepoErrors[0].RepositoryName != "backend" { + t.Errorf("per-repo error subject: got %q, want %q", + got.PerRepoErrors[0].RepositoryName, "backend") + } + if got.PerRepoErrors[0].Error != "repo not found" { + t.Errorf("per-repo error text: got %q, want %q", + got.PerRepoErrors[0].Error, "repo not found") + } + if len(got.Commits) != 1 || got.Commits[0].CommitSHA != "a" { + t.Errorf("expected 1 commit from frontend; got %v", got.Commits) + } + if got.Commits[0].RepositoryName != "frontend" { + t.Errorf("commit RepositoryName not stamped: got %q", got.Commits[0].RepositoryName) + } + }) + + t.Run("partial failure: result.Success=false from per-repo", func(t *testing.T) { + outcomes := []perRepoLogOutcome{ + { + subpath: "frontend", + result: &process.GitLogResult{ + Success: true, + Commits: []*process.GitCommitInfo{{CommitSHA: "a", CommittedAt: "2026-04-26T10:00:00Z"}}, + }, + }, + { + subpath: "backend", + result: &process.GitLogResult{Success: false, Error: "git log: bad ref"}, + }, + } + got := mergeGitLogResults(outcomes, 100) + if !got.Success { + t.Errorf("expected Success=true with partial failure") + } + if len(got.PerRepoErrors) != 1 || got.PerRepoErrors[0].Error != "git log: bad ref" { + t.Errorf("expected PerRepoErrors carrying the inner error; got %+v", got.PerRepoErrors) + } + }) + + t.Run("all repos fail: Success=false with summary error", func(t *testing.T) { + outcomes := []perRepoLogOutcome{ + {subpath: "frontend", err: errors.New("transport boom")}, + {subpath: "backend", result: &process.GitLogResult{Success: false, Error: "bad ref"}}, + } + got := mergeGitLogResults(outcomes, 100) + if got.Success { + t.Errorf("expected Success=false when every repo failed") + } + if got.Error == "" { + t.Errorf("expected non-empty Error summary when every repo failed") + } + if len(got.PerRepoErrors) != 2 { + t.Fatalf("expected 2 per-repo errors; got %d", len(got.PerRepoErrors)) + } + // Order matches input order. + if got.PerRepoErrors[0].RepositoryName != "frontend" || + got.PerRepoErrors[1].RepositoryName != "backend" { + t.Errorf("per-repo error order: got %+v", got.PerRepoErrors) + } + if len(got.Commits) != 0 { + t.Errorf("expected no commits; got %d", len(got.Commits)) + } + }) + + t.Run("all repos succeed: Success=true with empty PerRepoErrors", func(t *testing.T) { + outcomes := []perRepoLogOutcome{ + { + subpath: "frontend", + result: &process.GitLogResult{ + Success: true, + Commits: []*process.GitCommitInfo{{CommitSHA: "a", CommittedAt: "2026-04-26T10:00:00Z"}}, + }, + }, + { + subpath: "backend", + result: &process.GitLogResult{ + Success: true, + Commits: []*process.GitCommitInfo{{CommitSHA: "b", CommittedAt: "2026-04-26T11:00:00Z"}}, + }, + }, + } + got := mergeGitLogResults(outcomes, 100) + if !got.Success { + t.Errorf("expected Success=true; Error=%q", got.Error) + } + if got.Error != "" { + t.Errorf("expected empty Error on full success; got %q", got.Error) + } + if len(got.PerRepoErrors) != 0 { + t.Errorf("expected empty PerRepoErrors; got %+v", got.PerRepoErrors) + } + if len(got.Commits) != 2 { + t.Fatalf("expected 2 commits merged; got %d", len(got.Commits)) + } + // Newest first ordering. + if got.Commits[0].CommitSHA != "b" || got.Commits[1].CommitSHA != "a" { + t.Errorf("expected newest-first order [b, a]; got [%s, %s]", + got.Commits[0].CommitSHA, got.Commits[1].CommitSHA) + } + // RepositoryName stamped. + if got.Commits[0].RepositoryName != "backend" || got.Commits[1].RepositoryName != "frontend" { + t.Errorf("RepositoryName not stamped correctly: %+v", got.Commits) + } + }) + + t.Run("empty outcomes: Success=true with no commits or errors", func(t *testing.T) { + // Defensive: callers shouldn't invoke us with zero subpaths, but if + // they do we should not classify "no work" as "everything failed". + got := mergeGitLogResults(nil, 100) + if !got.Success { + t.Errorf("expected Success=true on empty input; got false (Error=%q)", got.Error) + } + if len(got.Commits) != 0 || len(got.PerRepoErrors) != 0 { + t.Errorf("expected empty result on empty input; got %+v", got) + } + }) +} diff --git a/apps/backend/internal/agentctl/server/api/server.go b/apps/backend/internal/agentctl/server/api/server.go index 7206e8a3c7..0264c6a346 100644 --- a/apps/backend/internal/agentctl/server/api/server.go +++ b/apps/backend/internal/agentctl/server/api/server.go @@ -155,6 +155,7 @@ func (s *Server) setupRoutes() { api.GET("/git/log", s.handleGitLog) api.GET("/git/cumulative-diff", s.handleGitCumulativeDiff) api.GET("/git/status", s.handleGitStatus) + api.GET("/git/status/multi", s.handleGitStatusMulti) } // Utility agent routes diff --git a/apps/backend/internal/agentctl/server/api/workspace.go b/apps/backend/internal/agentctl/server/api/workspace.go index 3cca6c3a08..1e23807a11 100644 --- a/apps/backend/internal/agentctl/server/api/workspace.go +++ b/apps/backend/internal/agentctl/server/api/workspace.go @@ -27,9 +27,12 @@ func (s *Server) handleWorkspaceStreamWS(c *gin.Context) { s.logger.Info("Workspace stream WebSocket connected") - // Subscribe to unified workspace updates - sub := s.procMgr.GetWorkspaceTracker().SubscribeWorkspaceStream() - defer s.procMgr.GetWorkspaceTracker().UnsubscribeWorkspaceStream(sub) + // Subscribe to unified workspace updates. Manager.SubscribeWorkspaceStream + // fans out across the root tracker plus every per-repo tracker, so for + // multi-repo task roots the client receives one event per repo (each + // tagged with RepositoryName) on a single channel. + sub := s.procMgr.SubscribeWorkspaceStream() + defer s.procMgr.UnsubscribeWorkspaceStream(sub) // Get shell for input handling (may be nil) shell := s.procMgr.Shell() @@ -108,7 +111,9 @@ func (s *Server) handleFileTree(c *gin.Context) { c.JSON(200, types.FileTreeResponse{Root: tree}) } -// handleFileContent handles file content requests via HTTP GET +// handleFileContent handles file content requests via HTTP GET. +// Optional `repo` query param scopes the read to a per-repo subdirectory for +// multi-repo task workspaces; `path` is treated as repo-relative when set. func (s *Server) handleFileContent(c *gin.Context) { path := c.Query("path") if path == "" { @@ -116,16 +121,26 @@ func (s *Server) handleFileContent(c *gin.Context) { return } - content, size, isBinary, resolvedPath, err := s.procMgr.GetWorkspaceTracker().GetFileContent(path) + scopedPath, err := s.procMgr.JoinRepoPath(c.Query("repo"), path) + if err != nil { + c.JSON(400, types.FileContentResponse{Path: path, Error: err.Error()}) + return + } + + content, size, isBinary, resolvedPath, err := s.procMgr.GetWorkspaceTracker().GetFileContent(scopedPath) if err != nil { c.JSON(400, types.FileContentResponse{Path: path, Error: err.Error(), Size: size}) return } + // Return the repo-relative `path` to the caller — the `repo` scoping is an + // internal detail; the frontend tracks repository_name separately. c.JSON(200, types.FileContentResponse{Path: path, Content: content, Size: size, IsBinary: isBinary, ResolvedPath: resolvedPath}) } -// handleFileContentAtRef handles file content requests at a specific git ref via HTTP GET +// handleFileContentAtRef handles file content requests at a specific git ref via HTTP GET. +// Optional `repo` query param scopes the read to a per-repo subdirectory for +// multi-repo task workspaces. func (s *Server) handleFileContentAtRef(c *gin.Context) { path := c.Query("path") if path == "" { @@ -139,7 +154,13 @@ func (s *Server) handleFileContentAtRef(c *gin.Context) { return } - content, size, isBinary, err := s.procMgr.GetWorkspaceTracker().GetFileContentAtRef(c.Request.Context(), path, ref) + scopedPath, err := s.procMgr.JoinRepoPath(c.Query("repo"), path) + if err != nil { + c.JSON(400, types.FileContentResponse{Path: path, Error: err.Error()}) + return + } + + content, size, isBinary, err := s.procMgr.GetWorkspaceTracker().GetFileContentAtRef(c.Request.Context(), scopedPath, ref) if err != nil { c.JSON(400, types.FileContentResponse{Path: path, Error: err.Error(), Size: size}) return @@ -166,8 +187,18 @@ func (s *Server) handleFileUpdate(c *gin.Context) { return } + scopedPath, err := s.procMgr.JoinRepoPath(req.Repo, req.Path) + if err != nil { + c.JSON(400, streams.FileUpdateResponse{ + Path: req.Path, + Success: false, + Error: err.Error(), + }) + return + } + // Apply the diff - newHash, resolution, err := s.procMgr.GetWorkspaceTracker().ApplyFileDiff(req.Path, req.Diff, req.OriginalHash, req.DesiredContent) + newHash, resolution, err := s.procMgr.GetWorkspaceTracker().ApplyFileDiff(scopedPath, req.Diff, req.OriginalHash, req.DesiredContent) if err != nil { c.JSON(400, streams.FileUpdateResponse{ Path: req.Path, @@ -200,7 +231,11 @@ func (s *Server) handleFileSearch(c *gin.Context) { c.JSON(200, types.FileSearchResponse{Files: files}) } -// handleFileCreate handles file create requests via HTTP POST +// handleFileCreate handles file create requests via HTTP POST. +// Optional `Repo` body field scopes the create to a per-repo subdirectory +// for multi-repo task workspaces; without it a UI-driven create on a +// multi-repo task lands in the bare task root (one level above any actual +// repo) instead of inside the intended repo. func (s *Server) handleFileCreate(c *gin.Context) { var req streams.FileCreateRequest if err := c.ShouldBindJSON(&req); err != nil { @@ -213,9 +248,15 @@ func (s *Server) handleFileCreate(c *gin.Context) { return } - s.logger.Info("handleFileCreate: creating file", zap.String("path", req.Path)) + scopedPath, joinErr := s.procMgr.JoinRepoPath(req.Repo, req.Path) + if joinErr != nil { + c.JSON(400, streams.FileCreateResponse{Path: req.Path, Error: joinErr.Error()}) + return + } - err := s.procMgr.GetWorkspaceTracker().CreateFile(req.Path) + s.logger.Info("handleFileCreate: creating file", zap.String("path", req.Path), zap.String("repo", req.Repo)) + + err := s.procMgr.GetWorkspaceTracker().CreateFile(scopedPath) if err != nil { c.JSON(400, streams.FileCreateResponse{ Path: req.Path, @@ -233,7 +274,9 @@ func (s *Server) handleFileCreate(c *gin.Context) { }) } -// handleFileDelete handles file delete requests via HTTP DELETE +// handleFileDelete handles file delete requests via HTTP DELETE. +// Optional `repo` query param scopes the delete to a per-repo subdirectory +// for multi-repo task workspaces. func (s *Server) handleFileDelete(c *gin.Context) { path := c.Query("path") if path == "" { @@ -241,7 +284,13 @@ func (s *Server) handleFileDelete(c *gin.Context) { return } - err := s.procMgr.GetWorkspaceTracker().DeleteFile(path) + scopedPath, joinErr := s.procMgr.JoinRepoPath(c.Query("repo"), path) + if joinErr != nil { + c.JSON(400, streams.FileDeleteResponse{Path: path, Error: joinErr.Error()}) + return + } + + err := s.procMgr.GetWorkspaceTracker().DeleteFile(scopedPath) if err != nil { c.JSON(400, streams.FileDeleteResponse{ Path: path, @@ -257,7 +306,10 @@ func (s *Server) handleFileDelete(c *gin.Context) { }) } -// handleFileRename handles file/directory rename requests via HTTP POST +// handleFileRename handles file/directory rename requests via HTTP POST. +// Optional `Repo` body field scopes BOTH old_path and new_path to a per-repo +// subdirectory; cross-repo renames aren't supported (the rename collapses +// to a same-repo move). func (s *Server) handleFileRename(c *gin.Context) { var req streams.FileRenameRequest if err := c.ShouldBindJSON(&req); err != nil { @@ -274,7 +326,18 @@ func (s *Server) handleFileRename(c *gin.Context) { return } - err := s.procMgr.GetWorkspaceTracker().RenameFile(req.OldPath, req.NewPath) + scopedOld, oldErr := s.procMgr.JoinRepoPath(req.Repo, req.OldPath) + if oldErr != nil { + c.JSON(400, streams.FileRenameResponse{OldPath: req.OldPath, NewPath: req.NewPath, Error: oldErr.Error()}) + return + } + scopedNew, newErr := s.procMgr.JoinRepoPath(req.Repo, req.NewPath) + if newErr != nil { + c.JSON(400, streams.FileRenameResponse{OldPath: req.OldPath, NewPath: req.NewPath, Error: newErr.Error()}) + return + } + + err := s.procMgr.GetWorkspaceTracker().RenameFile(scopedOld, scopedNew) if err != nil { c.JSON(400, streams.FileRenameResponse{ OldPath: req.OldPath, diff --git a/apps/backend/internal/agentctl/server/api/workspace_state.go b/apps/backend/internal/agentctl/server/api/workspace_state.go index ef6e0537b6..0397f77dcf 100644 --- a/apps/backend/internal/agentctl/server/api/workspace_state.go +++ b/apps/backend/internal/agentctl/server/api/workspace_state.go @@ -19,6 +19,19 @@ type SetPollModeRequest struct { // See plan: focus-gated git polling. The gateway computes a workspace-level // mode (fast if any session focused, slow if any subscribed, paused otherwise) // and pushes it here so the tracker can throttle expensive git scans. +// +// Multi-repo: the per-repo trackers also need the mode update — otherwise they +// keep their construction-time default (fast) and never throttle, AND they +// miss the focus-triggered immediate scan that handleMonitorModeChange does +// on a transition INTO fast. Fanning out keeps every repo in lockstep. +// +// Snapshot-on-focus: a transition to fast/slow forces a RefreshGitStatus on +// every tracker so a fresh git status reaches subscribers. monitorTick +// otherwise only pushes on detected change; without this, a page opened +// after the agent has been idle would see no events until the user makes +// a file change. Multi-repo amplifies it: per-repo state is a Map and the +// aggregator only renders the repos that have published — missing events +// = missing files in the Changes panel, not just stale data. func (s *Server) handleSetPollMode(c *gin.Context) { var req SetPollModeRequest if err := c.ShouldBindJSON(&req); err != nil { @@ -32,7 +45,7 @@ func (s *Server) handleSetPollMode(c *gin.Context) { return } - s.procMgr.GetWorkspaceTracker().SetPollMode(mode) + s.procMgr.SetWorkspacePollMode(c.Request.Context(), mode) s.logger.Debug("workspace poll mode updated", zap.String("mode", req.Mode)) c.JSON(http.StatusOK, gin.H{"mode": req.Mode}) diff --git a/apps/backend/internal/agentctl/server/process/git.go b/apps/backend/internal/agentctl/server/process/git.go index ab56f108f0..8241cc6073 100644 --- a/apps/backend/internal/agentctl/server/process/git.go +++ b/apps/backend/internal/agentctl/server/process/git.go @@ -39,6 +39,10 @@ type GitOperator struct { workDir string logger *logger.Logger workspaceTracker *WorkspaceTracker + // repoName is the multi-repo subpath this operator runs in (e.g. "kandev"). + // Empty for the workspace-root operator. Stamped on emitted commit + // notifications so the frontend can group commits per repo. + repoName string mu sync.Mutex // Prevents concurrent git operations inProgress bool @@ -54,6 +58,14 @@ func NewGitOperator(workDir string, log *logger.Logger, workspaceTracker *Worksp } } +// NewGitOperatorForRepo creates a GitOperator scoped to a multi-repo subpath +// so its emitted events (e.g. commit notifications) carry the repo name. +func NewGitOperatorForRepo(workDir, repoName string, log *logger.Logger, workspaceTracker *WorkspaceTracker) *GitOperator { + op := NewGitOperator(workDir, log, workspaceTracker) + op.repoName = repoName + return op +} + // runGitCommand executes a git command in the workDir with defense-in-depth validation. // Validates both flags and branch/ref arguments to prevent command injection. func (g *GitOperator) runGitCommand(ctx context.Context, args ...string) (string, error) { @@ -503,15 +515,16 @@ func (g *GitOperator) Commit(ctx context.Context, message string, stageAll bool, filesChanged, insertions, deletions := g.getCommitStats(ctx, strings.TrimSpace(commitSHA)) commit := &streams.GitCommitNotification{ - CommitSHA: strings.TrimSpace(commitSHA), - ParentSHA: strings.TrimSpace(parentSHA), - Message: message, - AuthorName: authorName, - AuthorEmail: authorEmail, - FilesChanged: filesChanged, - Insertions: insertions, - Deletions: deletions, - CommittedAt: time.Now().UTC(), + RepositoryName: g.repoName, + CommitSHA: strings.TrimSpace(commitSHA), + ParentSHA: strings.TrimSpace(parentSHA), + Message: message, + AuthorName: authorName, + AuthorEmail: authorEmail, + FilesChanged: filesChanged, + Insertions: insertions, + Deletions: deletions, + CommittedAt: time.Now().UTC(), } g.workspaceTracker.NotifyGitCommit(commit) @@ -830,9 +843,10 @@ func (g *GitOperator) Reset(ctx context.Context, commitSHA string, mode string) g.logger.Warn("failed to resolve HEAD after reset", zap.Error(headErr)) } else { reset := &streams.GitResetNotification{ - Timestamp: time.Now().UTC(), - PreviousHead: previousHead, - CurrentHead: strings.TrimSpace(newHead), + Timestamp: time.Now().UTC(), + RepositoryName: g.repoName, + PreviousHead: previousHead, + CurrentHead: strings.TrimSpace(newHead), } g.workspaceTracker.NotifyGitReset(reset) } diff --git a/apps/backend/internal/agentctl/server/process/git_log.go b/apps/backend/internal/agentctl/server/process/git_log.go index 06e02a988a..afd2d14bf3 100644 --- a/apps/backend/internal/agentctl/server/process/git_log.go +++ b/apps/backend/internal/agentctl/server/process/git_log.go @@ -11,6 +11,18 @@ type GitLogResult struct { Success bool `json:"success"` Commits []*GitCommitInfo `json:"commits"` Error string `json:"error,omitempty"` + // PerRepoErrors lists per-repo failures during a multi-repo log fan-out. + // Empty/nil for single-repo responses or when every repo succeeded. The + // merged response keeps Success=true as long as at least one repo + // succeeded; callers inspect this slice to surface partial failures. + PerRepoErrors []GitLogRepoError `json:"per_repo_errors,omitempty"` +} + +// GitLogRepoError describes a single per-repo failure from a multi-repo log +// fan-out. It is omitted from the response entirely for single-repo responses. +type GitLogRepoError struct { + RepositoryName string `json:"repository_name"` + Error string `json:"error"` } // GitCommitInfo represents a single commit in the log. @@ -25,6 +37,9 @@ type GitCommitInfo struct { FilesChanged int `json:"files_changed"` Insertions int `json:"insertions"` Deletions int `json:"deletions"` + // RepositoryName tags commits returned from a multi-repo log fan-out. + // Empty for single-repo workspaces. Set by the API layer after the call. + RepositoryName string `json:"repository_name,omitempty"` } // CumulativeDiffResult represents the cumulative diff from base commit to HEAD. diff --git a/apps/backend/internal/agentctl/server/process/manager.go b/apps/backend/internal/agentctl/server/process/manager.go index de8a24a926..04ebfa3857 100644 --- a/apps/backend/internal/agentctl/server/process/manager.go +++ b/apps/backend/internal/agentctl/server/process/manager.go @@ -18,6 +18,7 @@ import ( "github.com/kandev/kandev/internal/agentctl/server/adapter" "github.com/kandev/kandev/internal/agentctl/server/config" "github.com/kandev/kandev/internal/agentctl/server/shell" + "github.com/kandev/kandev/internal/agentctl/types" "github.com/kandev/kandev/internal/agentctl/types/streams" "github.com/kandev/kandev/internal/common/logger" tools "github.com/kandev/kandev/internal/tools/installer" @@ -84,6 +85,16 @@ type Manager struct { // Workspace tracker for git status and file changes workspaceTracker *WorkspaceTracker + // repoTrackers holds per-repository trackers for multi-repo task roots. + // Each tracker stamps RepositoryName onto its emitted events and shares + // subscriber channels with the root via the Manager fan-out. + // Empty for single-repo workspaces. + repoTrackers []*WorkspaceTracker + // workspaceTrackersBySubpath caches per-subpath trackers for multi-repo + // task roots. Key is the cleaned subpath (relative to cfg.WorkDir). The + // root tracker lives in workspaceTracker above. + workspaceTrackersBySubpath map[string]*WorkspaceTracker + workspaceTrackersMu sync.Mutex // Script/process runner (dev server, setup, cleanup, custom) processRunner *ProcessRunner @@ -112,6 +123,10 @@ type Manager struct { // Git operator for git operations (lazy-initialized) gitOperator *GitOperator gitOperatorMu sync.Mutex + // gitOperatorsBySubpath caches per-subpath operators for multi-repo task + // roots. Key is the cleaned subpath (relative to cfg.WorkDir); empty key + // is reserved for the root operator and lives in gitOperator above. + gitOperatorsBySubpath map[string]*GitOperator // Final command string (full command with all adapter args) finalCommand string @@ -130,10 +145,27 @@ func NewManager(cfg *config.InstanceConfig, log *logger.Logger) *Manager { m := &Manager{ cfg: cfg, logger: log.WithFields(zap.String("component", "process-manager")), - workspaceTracker: NewWorkspaceTracker(cfg.WorkDir, log), updatesCh: make(chan adapter.AgentEvent, 100), pendingPermissions: make(map[string]*PendingPermission), } + // Multi-repo task roots hold one git worktree per repository as siblings. + // In that case build a per-repo tracker for each child so each emits its + // own GitStatusUpdate (tagged with RepositoryName) and the changes panel + // can show all repos. The root tracker covers the single-repo case via + // preferGitRepoChildIfRootIsBare; we skip its fallback when we've already + // detected a multi-repo root to avoid double-tracking the first repo. + repoChildren := scanRepositorySubdirs(cfg.WorkDir) + if len(repoChildren) >= 2 { + // Multi-repo: root tracker bound to the bare task root (no fallback, + // no events), plus one tracker per repo subdir. + m.workspaceTracker = NewWorkspaceTrackerForRepo(cfg.WorkDir, "", log) + for _, child := range repoChildren { + m.repoTrackers = append(m.repoTrackers, + NewWorkspaceTrackerForRepo(child.path, child.name, log)) + } + } else { + m.workspaceTracker = NewWorkspaceTracker(cfg.WorkDir, log) + } m.processRunner = NewProcessRunner(m.workspaceTracker, log, cfg.ProcessBufferMaxBytes) m.shellMgr = shell.NewManager(cfg.WorkDir, log) m.status.Store(StatusStopped) @@ -141,6 +173,38 @@ func NewManager(cfg *config.InstanceConfig, log *logger.Logger) *Manager { return m } +// repositorySubdir is one git-repo child of a multi-repo task root. +type repositorySubdir struct { + name string // directory basename (used as RepositoryName on emitted events) + path string // absolute path to the repo subdir +} + +// scanRepositorySubdirs returns the immediate child directories of workDir +// that are themselves git repositories or worktrees. Returns an empty slice +// when workDir doesn't exist, isn't readable, or contains zero git children. +// Used to detect multi-repo task roots at Manager construction. +func scanRepositorySubdirs(workDir string) []repositorySubdir { + if workDir == "" { + return nil + } + entries, err := os.ReadDir(workDir) + if err != nil { + return nil + } + var out []repositorySubdir + for _, entry := range entries { + if !entry.IsDir() || strings.HasPrefix(entry.Name(), ".") { + continue + } + candidate := filepath.Join(workDir, entry.Name()) + if resolveGitIndexPath(candidate) == "" { + continue + } + out = append(out, repositorySubdir{name: entry.Name(), path: candidate}) + } + return out +} + // Status returns the current process status func (m *Manager) Status() Status { return m.status.Load().(Status) @@ -166,6 +230,65 @@ func (m *Manager) GetWorkspaceTracker() *WorkspaceTracker { return m.workspaceTracker } +// SubscribeWorkspaceStream creates a single workspace stream subscriber and +// fans it out across the root tracker plus every per-repo tracker, so the +// caller receives events from all repositories on one channel. Use +// UnsubscribeWorkspaceStream to detach and close. +// +// For single-repo workspaces this is equivalent to +// GetWorkspaceTracker().SubscribeWorkspaceStream() — the per-repo list is +// empty and only the root tracker fires events. +func (m *Manager) SubscribeWorkspaceStream() types.WorkspaceStreamSubscriber { + sub := make(types.WorkspaceStreamSubscriber, 100) + m.workspaceTracker.AttachWorkspaceStreamSubscriber(sub) + for _, t := range m.repoTrackers { + t.AttachWorkspaceStreamSubscriber(sub) + } + return sub +} + +// UnsubscribeWorkspaceStream detaches the subscriber from every tracker and +// closes the channel exactly once. +func (m *Manager) UnsubscribeWorkspaceStream(sub types.WorkspaceStreamSubscriber) { + m.workspaceTracker.DetachWorkspaceStreamSubscriber(sub) + for _, t := range m.repoTrackers { + t.DetachWorkspaceStreamSubscriber(sub) + } + close(sub) +} + +// GetWorkspaceTrackerFor returns a workspace tracker scoped to a sub-directory +// of the workspace. Used by multi-repo task roots where each repository lives +// at {WorkDir}/{subpath}. Empty subpath returns the root tracker. +// +// Per-subpath trackers are created lazily on first request and cached. They +// are NOT started (no polling goroutines) — the multi-repo path uses them +// only for synchronous git-status queries via GetCurrentGitStatus, which +// falls through to getGitStatus when no cache is present. This keeps the +// per-repo cost cheap while preserving the long-running polling behavior of +// the root tracker for the agent's primary workspace. +func (m *Manager) GetWorkspaceTrackerFor(subpath string) (*WorkspaceTracker, error) { + cleaned, full, err := m.resolveSubpath(subpath) + if err != nil { + return nil, err + } + if cleaned == "" { + return m.workspaceTracker, nil + } + + m.workspaceTrackersMu.Lock() + defer m.workspaceTrackersMu.Unlock() + if m.workspaceTrackersBySubpath == nil { + m.workspaceTrackersBySubpath = make(map[string]*WorkspaceTracker) + } + if t, ok := m.workspaceTrackersBySubpath[cleaned]; ok { + return t, nil + } + t := NewWorkspaceTracker(full, m.logger) + m.workspaceTrackersBySubpath[cleaned] = t + return t, nil +} + // StartProcess runs a script/process with isolated stdout/stderr. func (m *Manager) StartProcess(ctx context.Context, req StartProcessRequest) (*ProcessInfo, error) { if m.processRunner == nil { @@ -198,8 +321,52 @@ func (m *Manager) ListProcesses(sessionID string) []ProcessInfo { return m.processRunner.List(sessionID) } -// GitOperator returns the git operator for git operations. -// The operator is lazy-initialized on first call. +// RepoSubpaths returns the subpath name (relative to cfg.WorkDir) for every +// per-repo tracker discovered at construction time. Empty for single-repo +// workspaces. Used by callers that want to fan an op out across repos. +func (m *Manager) RepoSubpaths() []string { + out := make([]string, 0, len(m.repoTrackers)) + for _, t := range m.repoTrackers { + if t.repositoryName != "" { + out = append(out, t.repositoryName) + } + } + return out +} + +// SetWorkspacePollMode propagates a poll-mode change to the root tracker and +// every per-repo tracker, then forces a RefreshGitStatus on each non-paused +// tracker so a fresh snapshot reaches every subscriber. Without the refresh, +// monitorTick only pushes on detected change — multi-repo workspaces would +// leave the per-repo state map sparse (one repo present, the other missing) +// after a focus event, since the agent's initial pushes happen at boot and +// no replay path exists for clients that subscribe later. +func (m *Manager) SetWorkspacePollMode(ctx context.Context, mode PollMode) { + m.workspaceTracker.SetPollMode(mode) + for _, t := range m.repoTrackers { + t.SetPollMode(mode) + } + if mode == PollModePaused { + return + } + // Snapshot the tracker slice before launching the goroutine so a + // concurrent Stop()/teardown that mutates m.repoTrackers can't race or + // nil-deref the iteration. Refresh in background — RefreshGitStatus + // blocks on git commands which can take seconds on large repos; the + // HTTP caller shouldn't wait. + root := m.workspaceTracker + trackers := make([]*WorkspaceTracker, len(m.repoTrackers)) + copy(trackers, m.repoTrackers) + go func() { + root.RefreshGitStatus(ctx) + for _, t := range trackers { + t.RefreshGitStatus(ctx) + } + }() +} + +// GitOperator returns the git operator for git operations against the +// workspace root. Lazy-initialized. func (m *Manager) GitOperator() *GitOperator { m.gitOperatorMu.Lock() defer m.gitOperatorMu.Unlock() @@ -210,6 +377,87 @@ func (m *Manager) GitOperator() *GitOperator { return m.gitOperator } +// GitOperatorFor returns a git operator scoped to a sub-directory of the +// workspace. Used by multi-repo task roots where each repository lives at +// {WorkDir}/{subpath}. Empty subpath returns the root operator. +// +// The subpath is validated to prevent path-traversal: it must be a clean +// relative path with no parent-references and must resolve to an existing +// directory inside cfg.WorkDir. +func (m *Manager) GitOperatorFor(subpath string) (*GitOperator, error) { + cleaned, full, err := m.resolveSubpath(subpath) + if err != nil { + return nil, err + } + if cleaned == "" { + return m.GitOperator(), nil + } + + m.gitOperatorMu.Lock() + defer m.gitOperatorMu.Unlock() + if m.gitOperatorsBySubpath == nil { + m.gitOperatorsBySubpath = make(map[string]*GitOperator) + } + if op, ok := m.gitOperatorsBySubpath[cleaned]; ok { + return op, nil + } + op := NewGitOperatorForRepo(full, cleaned, m.logger, m.workspaceTracker) + m.gitOperatorsBySubpath[cleaned] = op + return op, nil +} + +// resolveSubpath normalises and validates a repo subpath relative to +// cfg.WorkDir. Returns ("", "", nil) for the root (empty/"."); otherwise +// returns the cleaned relative path and the absolute full path. +// +// Rejects: parent-references, absolute paths, paths containing "..", +// and paths that don't resolve to an existing directory. +func (m *Manager) resolveSubpath(subpath string) (string, string, error) { + subpath = strings.TrimSpace(subpath) + if subpath == "" || subpath == "." { + return "", m.cfg.WorkDir, nil + } + + cleaned := filepath.Clean(subpath) + if cleaned == "." || cleaned == ".." || strings.HasPrefix(cleaned, "..") { + return "", "", fmt.Errorf("invalid repo subpath: %q", subpath) + } + if filepath.IsAbs(cleaned) { + return "", "", fmt.Errorf("repo subpath must be relative: %q", subpath) + } + for _, part := range strings.Split(cleaned, string(filepath.Separator)) { + if part == ".." { + return "", "", fmt.Errorf("repo subpath escapes workspace: %q", subpath) + } + } + + full := filepath.Join(m.cfg.WorkDir, cleaned) + info, err := os.Stat(full) + if err != nil { + return "", "", fmt.Errorf("repo subpath not found: %w", err) + } + if !info.IsDir() { + return "", "", fmt.Errorf("repo subpath is not a directory: %q", subpath) + } + return cleaned, full, nil +} + +// JoinRepoPath validates a repo subpath and returns the workspace-relative +// path obtained by joining `subpath` and `path`. Empty `subpath` returns +// `path` unchanged (single-repo workspaces). Used by file content / update +// handlers to scope a per-repo path under the right repository directory +// before delegating to the workspace tracker. +func (m *Manager) JoinRepoPath(subpath, path string) (string, error) { + cleaned, _, err := m.resolveSubpath(subpath) + if err != nil { + return "", err + } + if cleaned == "" { + return path, nil + } + return filepath.Join(cleaned, path), nil +} + // Start starts the agent process func (m *Manager) Start(ctx context.Context) error { m.startMu.Lock() @@ -284,6 +532,9 @@ func (m *Manager) Start(ctx context.Context) error { // Start workspace tracker with background context (not tied to HTTP request) m.workspaceTracker.Start(context.Background()) + for _, t := range m.repoTrackers { + t.Start(context.Background()) + } // Auto-create shell session if enabled m.startAgentShell() @@ -306,6 +557,9 @@ func (m *Manager) startOneShot() error { // Start workspace tracker with background context (not tied to HTTP request) m.workspaceTracker.Start(context.Background()) + for _, t := range m.repoTrackers { + t.Start(context.Background()) + } // Auto-create shell session if enabled m.startAgentShell() @@ -661,6 +915,9 @@ func (m *Manager) stopShellAndProcesses(ctx context.Context) { if m.workspaceTracker != nil { m.workspaceTracker.Stop() } + for _, t := range m.repoTrackers { + t.Stop() + } m.logger.Debug("workspace tracker stopped") } diff --git a/apps/backend/internal/agentctl/server/process/manager_git_subpath_test.go b/apps/backend/internal/agentctl/server/process/manager_git_subpath_test.go new file mode 100644 index 0000000000..69df498630 --- /dev/null +++ b/apps/backend/internal/agentctl/server/process/manager_git_subpath_test.go @@ -0,0 +1,117 @@ +package process + +import ( + "os" + "path/filepath" + "testing" + + "github.com/kandev/kandev/internal/agentctl/server/config" + "github.com/kandev/kandev/internal/common/logger" +) + +func newTestManager(t *testing.T, workDir string) *Manager { + t.Helper() + log, _ := logger.NewLogger(logger.LoggingConfig{Level: "error", Format: "json"}) + return NewManager(&config.InstanceConfig{WorkDir: workDir}, log) +} + +func TestManager_GitOperatorFor_EmptySubpathReturnsRoot(t *testing.T) { + tmp := t.TempDir() + mgr := newTestManager(t, tmp) + + op, err := mgr.GitOperatorFor("") + if err != nil { + t.Fatalf("empty subpath: %v", err) + } + if op == nil || op.workDir != tmp { + t.Errorf("expected root operator, got %+v", op) + } +} + +func TestManager_GitOperatorFor_ValidSubpathReturnsScopedOperator(t *testing.T) { + tmp := t.TempDir() + subDir := filepath.Join(tmp, "frontend") + if err := os.MkdirAll(subDir, 0755); err != nil { + t.Fatalf("mkdir: %v", err) + } + mgr := newTestManager(t, tmp) + + op, err := mgr.GitOperatorFor("frontend") + if err != nil { + t.Fatalf("subpath: %v", err) + } + if op.workDir != subDir { + t.Errorf("expected workDir %q, got %q", subDir, op.workDir) + } + + // Cached on second call. + op2, _ := mgr.GitOperatorFor("frontend") + if op2 != op { + t.Error("expected cached operator on second call") + } +} + +func TestManager_GitOperatorFor_RejectsPathTraversal(t *testing.T) { + tmp := t.TempDir() + mgr := newTestManager(t, tmp) + + cases := []string{"..", "../escape", "frontend/..", "/abs/path"} + for _, c := range cases { + if _, err := mgr.GitOperatorFor(c); err == nil { + t.Errorf("expected error for %q, got nil", c) + } + } +} + +func TestManager_GitOperatorFor_RejectsMissingDir(t *testing.T) { + tmp := t.TempDir() + mgr := newTestManager(t, tmp) + + if _, err := mgr.GitOperatorFor("does-not-exist"); err == nil { + t.Error("expected error for missing directory") + } +} + +func TestManager_GetWorkspaceTrackerFor_EmptySubpathReturnsRoot(t *testing.T) { + tmp := t.TempDir() + mgr := newTestManager(t, tmp) + + wt, err := mgr.GetWorkspaceTrackerFor("") + if err != nil { + t.Fatalf("empty subpath: %v", err) + } + if wt != mgr.GetWorkspaceTracker() { + t.Errorf("expected root tracker, got distinct instance") + } +} + +func TestManager_GetWorkspaceTrackerFor_ValidSubpathCachesPerSubpath(t *testing.T) { + tmp := t.TempDir() + subDir := filepath.Join(tmp, "frontend") + if err := os.MkdirAll(subDir, 0755); err != nil { + t.Fatalf("mkdir: %v", err) + } + mgr := newTestManager(t, tmp) + + wt1, err := mgr.GetWorkspaceTrackerFor("frontend") + if err != nil { + t.Fatalf("subpath: %v", err) + } + if wt1 == nil || wt1 == mgr.GetWorkspaceTracker() { + t.Errorf("expected dedicated subpath tracker, got root") + } + wt2, _ := mgr.GetWorkspaceTrackerFor("frontend") + if wt1 != wt2 { + t.Error("expected per-subpath tracker to be cached") + } +} + +func TestManager_GetWorkspaceTrackerFor_RejectsTraversal(t *testing.T) { + tmp := t.TempDir() + mgr := newTestManager(t, tmp) + for _, c := range []string{"..", "../escape", "/abs/path"} { + if _, err := mgr.GetWorkspaceTrackerFor(c); err == nil { + t.Errorf("expected error for %q, got nil", c) + } + } +} diff --git a/apps/backend/internal/agentctl/server/process/manager_multi_repo_stream_test.go b/apps/backend/internal/agentctl/server/process/manager_multi_repo_stream_test.go new file mode 100644 index 0000000000..8e863df276 --- /dev/null +++ b/apps/backend/internal/agentctl/server/process/manager_multi_repo_stream_test.go @@ -0,0 +1,140 @@ +package process + +import ( + "context" + "os" + "path/filepath" + "testing" + "time" + + "github.com/kandev/kandev/internal/agentctl/server/config" + "github.com/kandev/kandev/internal/agentctl/types" +) + +// Verifies the Manager fans out subscriptions across per-repo trackers and +// emits GitStatusUpdates tagged with RepositoryName for each repo in a +// multi-repo task root. This is the wire contract the frontend's +// per-repo Changes panel depends on. +func TestManager_SubscribeWorkspaceStream_MultiRepoEmitsPerRepoStatuses(t *testing.T) { + taskRoot := t.TempDir() + + // Build two real git repos as siblings under the task root. + for _, name := range []string{"frontend", "backend"} { + repoDir, cleanup := setupTestRepo(t) + t.Cleanup(cleanup) + dst := filepath.Join(taskRoot, name) + if err := os.Rename(repoDir, dst); err != nil { + t.Fatalf("place %s: %v", name, err) + } + } + + mgr := NewManager(&config.InstanceConfig{WorkDir: taskRoot}, newTestLogger(t)) + if len(mgr.repoTrackers) != 2 { + t.Fatalf("expected 2 per-repo trackers, got %d", len(mgr.repoTrackers)) + } + + // Start the trackers so the subscriber's replayed status is non-empty. + mgr.workspaceTracker.Start(context.Background()) + for _, tr := range mgr.repoTrackers { + tr.Start(context.Background()) + } + t.Cleanup(func() { + mgr.workspaceTracker.Stop() + for _, tr := range mgr.repoTrackers { + tr.Stop() + } + }) + + sub := mgr.SubscribeWorkspaceStream() + defer mgr.UnsubscribeWorkspaceStream(sub) + + // On subscribe each tracker replays its current status. Drain up to + // 3 messages (root + 2 repos) and capture the per-repo names seen. + deadline := time.After(2 * time.Second) + seen := map[string]bool{} + for len(seen) < 2 { + select { + case msg := <-sub: + if msg.Type != types.WorkspaceMessageTypeGitStatus || msg.GitStatus == nil { + continue + } + if name := msg.GitStatus.RepositoryName; name != "" { + seen[name] = true + } + case <-deadline: + t.Fatalf("timed out waiting for per-repo status messages; got %v", seen) + } + } + if !seen["frontend"] || !seen["backend"] { + t.Errorf("expected both repos to emit; got %v", seen) + } +} + +// Single-repo workspaces must NOT spawn per-repo trackers — keeps the +// pre-multi-repo behavior verbatim and ensures GitStatusUpdate.RepositoryName +// stays empty for legacy clients. +func TestManager_SingleRepo_NoPerRepoTrackers(t *testing.T) { + repoDir, cleanup := setupTestRepo(t) + defer cleanup() + + mgr := NewManager(&config.InstanceConfig{WorkDir: repoDir}, newTestLogger(t)) + if len(mgr.repoTrackers) != 0 { + t.Errorf("single-repo workspace should not have per-repo trackers; got %d", len(mgr.repoTrackers)) + } +} + +// RepoSubpaths must list every per-repo tracker's directory name (and +// nothing else) so the API layer can fan git-log out across repos. +func TestManager_RepoSubpaths(t *testing.T) { + taskRoot := t.TempDir() + for _, name := range []string{"frontend", "backend"} { + repoDir, cleanup := setupTestRepo(t) + t.Cleanup(cleanup) + if err := os.Rename(repoDir, filepath.Join(taskRoot, name)); err != nil { + t.Fatalf("place %s: %v", name, err) + } + } + + mgr := NewManager(&config.InstanceConfig{WorkDir: taskRoot}, newTestLogger(t)) + subs := mgr.RepoSubpaths() + want := map[string]bool{"frontend": true, "backend": true} + if len(subs) != len(want) { + t.Fatalf("RepoSubpaths length = %d, want %d (got %v)", len(subs), len(want), subs) + } + for _, s := range subs { + if !want[s] { + t.Errorf("unexpected repo subpath %q", s) + } + } +} + +// Verifies that SetWorkspacePollMode propagates to every per-repo tracker. +// Without the fan-out, per-repo trackers stay at their construction-time +// default and never receive the focus-triggered refresh that delivers a +// fresh git status snapshot to a subscriber attached after agent boot. +func TestManager_SetWorkspacePollMode_PropagatesToPerRepoTrackers(t *testing.T) { + taskRoot := t.TempDir() + for _, name := range []string{"frontend", "backend"} { + repoDir, cleanup := setupTestRepo(t) + t.Cleanup(cleanup) + if err := os.Rename(repoDir, filepath.Join(taskRoot, name)); err != nil { + t.Fatalf("place %s: %v", name, err) + } + } + + mgr := NewManager(&config.InstanceConfig{WorkDir: taskRoot}, newTestLogger(t)) + if len(mgr.repoTrackers) != 2 { + t.Fatalf("expected 2 per-repo trackers, got %d", len(mgr.repoTrackers)) + } + + mgr.SetWorkspacePollMode(context.Background(), PollModeSlow) + + if got := mgr.workspaceTracker.GetPollMode(); got != PollModeSlow { + t.Errorf("root tracker mode = %v, want %v", got, PollModeSlow) + } + for i, tr := range mgr.repoTrackers { + if got := tr.GetPollMode(); got != PollModeSlow { + t.Errorf("repoTrackers[%d] mode = %v, want %v", i, got, PollModeSlow) + } + } +} diff --git a/apps/backend/internal/agentctl/server/process/workspace_files.go b/apps/backend/internal/agentctl/server/process/workspace_files.go index ce0256462b..dab2327207 100644 --- a/apps/backend/internal/agentctl/server/process/workspace_files.go +++ b/apps/backend/internal/agentctl/server/process/workspace_files.go @@ -47,8 +47,9 @@ func (wt *WorkspaceTracker) updateFiles(ctx context.Context) { // getFileList retrieves the list of files in the workspace func (wt *WorkspaceTracker) getFileList(ctx context.Context) (types.FileListUpdate, error) { update := types.FileListUpdate{ - Timestamp: time.Now(), - Files: []types.FileEntry{}, + Timestamp: time.Now(), + RepositoryName: wt.repositoryName, + Files: []types.FileEntry{}, } // Use git ls-files to get tracked files AND untracked files (excluding ignored) diff --git a/apps/backend/internal/agentctl/server/process/workspace_git_commits.go b/apps/backend/internal/agentctl/server/process/workspace_git_commits.go index 4e3d20f911..0121748df4 100644 --- a/apps/backend/internal/agentctl/server/process/workspace_git_commits.go +++ b/apps/backend/internal/agentctl/server/process/workspace_git_commits.go @@ -71,16 +71,17 @@ func (wt *WorkspaceTracker) getCommitsSince(ctx context.Context, baseCommit stri } commits = append(commits, &types.GitCommitNotification{ - Timestamp: time.Now(), - CommitSHA: sha, - ParentSHA: parentSHA, - AuthorName: parts[2], - AuthorEmail: parts[3], - Message: parts[4], - FilesChanged: filesChanged, - Insertions: insertions, - Deletions: deletions, - CommittedAt: committedAt, + Timestamp: time.Now(), + RepositoryName: wt.repositoryName, + CommitSHA: sha, + ParentSHA: parentSHA, + AuthorName: parts[2], + AuthorEmail: parts[3], + Message: parts[4], + FilesChanged: filesChanged, + Insertions: insertions, + Deletions: deletions, + CommittedAt: committedAt, }) } diff --git a/apps/backend/internal/agentctl/server/process/workspace_git_poll.go b/apps/backend/internal/agentctl/server/process/workspace_git_poll.go index 7f757d7d06..0dfa874725 100644 --- a/apps/backend/internal/agentctl/server/process/workspace_git_poll.go +++ b/apps/backend/internal/agentctl/server/process/workspace_git_poll.go @@ -265,9 +265,10 @@ func (wt *WorkspaceTracker) checkGitChanges(ctx context.Context) { zap.String("previous", previousHead), zap.String("current", currentHead)) wt.notifyWorkspaceStreamGitReset(&types.GitResetNotification{ - Timestamp: time.Now(), - PreviousHead: previousHead, - CurrentHead: currentHead, + Timestamp: time.Now(), + RepositoryName: wt.repositoryName, + PreviousHead: previousHead, + CurrentHead: currentHead, }) case !wt.isAncestor(ctx, previousHead, currentHead): // Case 2: History was rewritten - previousHead is not reachable from currentHead @@ -276,9 +277,10 @@ func (wt *WorkspaceTracker) checkGitChanges(ctx context.Context) { zap.String("previous", previousHead), zap.String("current", currentHead)) wt.notifyWorkspaceStreamGitReset(&types.GitResetNotification{ - Timestamp: time.Now(), - PreviousHead: previousHead, - CurrentHead: currentHead, + Timestamp: time.Now(), + RepositoryName: wt.repositoryName, + PreviousHead: previousHead, + CurrentHead: currentHead, }) default: // Case 3: HEAD moved forward normally - get new commits @@ -321,6 +323,7 @@ func (wt *WorkspaceTracker) handleBranchSwitch(ctx context.Context, previousBran if baseCommit != "" { wt.notifyWorkspaceStreamBranchSwitch(&types.GitBranchSwitchNotification{ Timestamp: time.Now(), + RepositoryName: wt.repositoryName, PreviousBranch: previousBranch, CurrentBranch: currentBranch, CurrentHead: currentHead, diff --git a/apps/backend/internal/agentctl/server/process/workspace_git_status.go b/apps/backend/internal/agentctl/server/process/workspace_git_status.go index 602f1cbccf..13a3660de4 100644 --- a/apps/backend/internal/agentctl/server/process/workspace_git_status.go +++ b/apps/backend/internal/agentctl/server/process/workspace_git_status.go @@ -67,13 +67,24 @@ func (wt *WorkspaceTracker) GetCurrentGitStatus(ctx context.Context) (types.GitS // getGitStatus retrieves the current git status func (wt *WorkspaceTracker) getGitStatus(ctx context.Context) (types.GitStatusUpdate, error) { update := types.GitStatusUpdate{ - Timestamp: time.Now(), - Modified: []string{}, - Added: []string{}, - Deleted: []string{}, - Untracked: []string{}, - Renamed: []string{}, - Files: make(map[string]types.FileInfo), + Timestamp: time.Now(), + RepositoryName: wt.repositoryName, + Modified: []string{}, + Added: []string{}, + Deleted: []string{}, + Untracked: []string{}, + Renamed: []string{}, + Files: make(map[string]types.FileInfo), + } + + // Bare trackers (multi-repo task roots) sit on a directory that isn't + // itself a git repo. Without this guard, `git status` would ascend the + // directory tree until it found a `.git` — for tasks nested inside a + // developer's own kandev checkout that lands on the OUTER worktree and + // silently emits its branch/ahead/behind as if it were the task. Bail + // out early to keep the bare tracker's `currentStatus` zero-valued. + if wt.gitIndexPath == "" { + return update, nil } if err := wt.getGitBranchInfo(ctx, &update); err != nil { diff --git a/apps/backend/internal/agentctl/server/process/workspace_monitor.go b/apps/backend/internal/agentctl/server/process/workspace_monitor.go index a3042c68bd..ef427cc51a 100644 --- a/apps/backend/internal/agentctl/server/process/workspace_monitor.go +++ b/apps/backend/internal/agentctl/server/process/workspace_monitor.go @@ -190,8 +190,9 @@ func (wt *WorkspaceTracker) monitorTick(ctx context.Context, lastState *workspac wt.tryUpdateGitStatus(ctx) wt.updateFiles(ctx) wt.notifyWorkspaceStreamFileChange(types.FileChangeNotification{ - Timestamp: time.Now(), - Operation: types.FileOpRefresh, + Timestamp: time.Now(), + RepositoryName: wt.repositoryName, + Operation: types.FileOpRefresh, }) } return false @@ -353,12 +354,19 @@ func (wt *WorkspaceTracker) emitFileChanges(changes []types.FileChangeNotificati const maxSpecificChanges = 50 if len(changes) == 0 || len(changes) > maxSpecificChanges { wt.notifyWorkspaceStreamFileChange(types.FileChangeNotification{ - Timestamp: time.Now(), - Operation: types.FileOpRefresh, + Timestamp: time.Now(), + RepositoryName: wt.repositoryName, + Operation: types.FileOpRefresh, }) return } for i := range changes { + // Stamp repository name on every emitted notification when this tracker + // is scoped to a multi-repo subpath. Callers that build the slice may + // not know the tracker's repo, so we set it here as the single source. + if changes[i].RepositoryName == "" { + changes[i].RepositoryName = wt.repositoryName + } wt.notifyWorkspaceStreamFileChange(changes[i]) } } @@ -368,8 +376,9 @@ func (wt *WorkspaceTracker) emitFileChanges(changes []types.FileChangeNotificati // subscribers without waiting for the next poll cycle. func (wt *WorkspaceTracker) notifyFileChange(relPath, operation string) { wt.notifyWorkspaceStreamFileChange(types.FileChangeNotification{ - Timestamp: time.Now(), - Path: relPath, - Operation: operation, + Timestamp: time.Now(), + RepositoryName: wt.repositoryName, + Path: relPath, + Operation: operation, }) } diff --git a/apps/backend/internal/agentctl/server/process/workspace_stream.go b/apps/backend/internal/agentctl/server/process/workspace_stream.go index 8a12147e71..e976c3784e 100644 --- a/apps/backend/internal/agentctl/server/process/workspace_stream.go +++ b/apps/backend/internal/agentctl/server/process/workspace_stream.go @@ -11,14 +11,33 @@ import ( // and sends current git status and file list immediately func (wt *WorkspaceTracker) SubscribeWorkspaceStream() types.WorkspaceStreamSubscriber { sub := make(types.WorkspaceStreamSubscriber, 100) + wt.AttachWorkspaceStreamSubscriber(sub) + return sub +} +// AttachWorkspaceStreamSubscriber registers an existing subscriber channel with +// this tracker and immediately replays the current git status. Used by the +// process Manager to fan out a single client subscription across multiple +// per-repo trackers (multi-repo task roots) without giving the client a +// channel per tracker. +func (wt *WorkspaceTracker) AttachWorkspaceStreamSubscriber(sub types.WorkspaceStreamSubscriber) { wt.workspaceSubMu.Lock() wt.workspaceStreamSubscribers[sub] = struct{}{} count := len(wt.workspaceStreamSubscribers) wt.workspaceSubMu.Unlock() wt.logger.Info("workspace stream subscriber added", zap.Int("subscribers", count)) - // Send current git status immediately + // Bare trackers (multi-repo task roots) don't track their own git state — + // `gitIndexPath == ""` means the workDir isn't itself a git repo, so any + // `currentStatus` here is either empty (initial) or stale from a previous + // shape of the tracker. Skip the replay so the subscriber doesn't see a + // ghost entry under the empty `repository_name`. + if wt.gitIndexPath == "" { + return + } + + // Replay current git status so the new subscriber doesn't have to wait for + // the next poll tick. wt.mu.RLock() currentStatus := wt.currentStatus wt.mu.RUnlock() @@ -26,23 +45,32 @@ func (wt *WorkspaceTracker) SubscribeWorkspaceStream() types.WorkspaceStreamSubs if currentStatus.Timestamp.IsZero() { currentStatus.Timestamp = time.Now() } + if currentStatus.RepositoryName == "" { + currentStatus.RepositoryName = wt.repositoryName + } - // Send git status select { case sub <- types.NewWorkspaceGitStatus(¤tStatus): default: } - - return sub } -// UnsubscribeWorkspaceStream removes and closes a workspace stream subscriber +// UnsubscribeWorkspaceStream removes a workspace stream subscriber and closes +// the channel. Use DetachWorkspaceStreamSubscriber when the same channel is +// shared across multiple trackers — only the final detach should close it. func (wt *WorkspaceTracker) UnsubscribeWorkspaceStream(sub types.WorkspaceStreamSubscriber) { + wt.DetachWorkspaceStreamSubscriber(sub) + close(sub) +} + +// DetachWorkspaceStreamSubscriber removes the subscriber from this tracker +// without closing its channel. Used by Manager.UnsubscribeWorkspaceStream +// which detaches from each tracker first and then closes once. +func (wt *WorkspaceTracker) DetachWorkspaceStreamSubscriber(sub types.WorkspaceStreamSubscriber) { wt.workspaceSubMu.Lock() delete(wt.workspaceStreamSubscribers, sub) count := len(wt.workspaceStreamSubscribers) wt.workspaceSubMu.Unlock() - close(sub) wt.logger.Info("workspace stream subscriber removed", zap.Int("subscribers", count)) } diff --git a/apps/backend/internal/agentctl/server/process/workspace_tracker.go b/apps/backend/internal/agentctl/server/process/workspace_tracker.go index 2e6524dcfc..1a4aff635c 100644 --- a/apps/backend/internal/agentctl/server/process/workspace_tracker.go +++ b/apps/backend/internal/agentctl/server/process/workspace_tracker.go @@ -31,6 +31,11 @@ type WorkspaceTracker struct { workDir string gitIndexPath string // Cached, validated path to git index file (works with worktrees) logger *logger.Logger + // repositoryName identifies the repository this tracker covers when the + // agent's workspace is a multi-repo task root. Stamped onto every emitted + // GitStatusUpdate / FileListUpdate so the frontend can key per-repo state. + // Empty for the single-repo case. + repositoryName string // Current state currentStatus types.GitStatusUpdate @@ -82,9 +87,39 @@ type WorkspaceTracker struct { cancelFunc context.CancelFunc // Cancel function called during Stop } -// NewWorkspaceTracker creates a new workspace tracker +// NewWorkspaceTracker creates a new workspace tracker for a single git +// repository (or a single workspace root that may or may not be a git repo). +// For multi-repo task roots use NewWorkspaceTrackerForRepo per repo subdir. func NewWorkspaceTracker(workDir string, log *logger.Logger) *WorkspaceTracker { - resolvedWorkDir := resolveExistingWorkDir(workDir, log.WithFields(zap.String("component", "workspace-tracker"))) + tlog := log.WithFields(zap.String("component", "workspace-tracker")) + resolvedWorkDir := resolveExistingWorkDir(workDir, tlog) + // Multi-repo task roots are plain directories that hold one git worktree + // per repository as siblings. The git poller can't monitor a non-git path, + // so when the configured workDir isn't a git repo, fall back to the first + // child subdirectory that is. This preserves single-repo behavior for + // callers using NewWorkspaceTracker; multi-repo callers should use + // NewWorkspaceTrackerForRepo per repo subdir to get per-repo events. + resolvedWorkDir = preferGitRepoChildIfRootIsBare(resolvedWorkDir, tlog) + return newWorkspaceTracker(resolvedWorkDir, "", log) +} + +// NewWorkspaceTrackerForRepo creates a tracker scoped to a specific repository +// subdirectory. The repositoryName is stamped onto every emitted event so the +// frontend can route updates per repo for multi-repo task roots. +func NewWorkspaceTrackerForRepo(workDir, repositoryName string, log *logger.Logger) *WorkspaceTracker { + tlog := log.WithFields( + zap.String("component", "workspace-tracker"), + zap.String("repository_name", repositoryName), + ) + resolvedWorkDir := resolveExistingWorkDir(workDir, tlog) + return newWorkspaceTracker(resolvedWorkDir, repositoryName, log) +} + +func newWorkspaceTracker(resolvedWorkDir, repositoryName string, log *logger.Logger) *WorkspaceTracker { + logFields := []zap.Field{zap.String("component", "workspace-tracker")} + if repositoryName != "" { + logFields = append(logFields, zap.String("repository_name", repositoryName)) + } // Cache validated git index path (works with worktrees where .git is a file) gitIndexPath := resolveGitIndexPath(resolvedWorkDir) @@ -94,7 +129,8 @@ func NewWorkspaceTracker(workDir string, log *logger.Logger) *WorkspaceTracker { return &WorkspaceTracker{ workDir: resolvedWorkDir, gitIndexPath: gitIndexPath, - logger: log.WithFields(zap.String("component", "workspace-tracker")), + repositoryName: repositoryName, + logger: log.WithFields(logFields...), workspaceStreamSubscribers: make(map[types.WorkspaceStreamSubscriber]struct{}), filePollInterval: DefaultFilePollInterval, gitPollInterval: DefaultGitPollInterval, @@ -117,10 +153,56 @@ func NewWorkspaceTracker(workDir string, log *logger.Logger) *WorkspaceTracker { } } +// preferGitRepoChildIfRootIsBare returns workDir unchanged when it's already a +// git repo, otherwise returns the path of the first immediate child directory +// that is a git repo (or worktree). Used to make the workspace tracker work +// for multi-repo task roots, which are plain holder directories with each +// repo's worktree as a child. +// +// Returns workDir unchanged when no git child is found — the tracker will +// then run with no git data, matching the pre-multi-repo behavior for +// repo-less tasks (e.g. quick chat). +func preferGitRepoChildIfRootIsBare(workDir string, log *logger.Logger) string { + if workDir == "" || resolveGitIndexPath(workDir) != "" { + return workDir + } + entries, err := os.ReadDir(workDir) + if err != nil { + return workDir + } + for _, entry := range entries { + if !entry.IsDir() || strings.HasPrefix(entry.Name(), ".") { + continue + } + candidate := filepath.Join(workDir, entry.Name()) + if resolveGitIndexPath(candidate) != "" { + log.Info("workspace tracker falling back to repo subdirectory (multi-repo task root is not itself a git repo)", + zap.String("task_root", workDir), + zap.String("repo_subdir", candidate)) + return candidate + } + } + return workDir +} + // resolveGitIndexPath returns the validated path to the git index file. // Returns empty string if the path cannot be resolved or validated. // This handles worktrees where .git is a file pointing elsewhere. +// +// Multi-repo task roots are NOT git repos themselves (they hold per-repo +// child worktrees as siblings), but `git rev-parse` ascends until it finds +// a `.git` — for tasks nested under a developer's own kandev checkout this +// would land on the OUTER worktree and silently emit its status as if it +// were the task. We guard against that by requiring the resolved git +// top-level to be the same path as workDir (or, for repo subdirs called +// here from `scanRepositorySubdirs`, an absolute git-dir reachable from the +// dir's own `.git` file). In practice this means: workDir must contain its +// own `.git` entry — file or directory — for the path to be considered a +// valid git repo for tracking. func resolveGitIndexPath(workDir string) string { + if !workDirHasOwnGitEntry(workDir) { + return "" + } cmd := exec.Command("git", "rev-parse", "--git-dir") cmd.Dir = workDir out, err := cmd.Output() @@ -142,6 +224,20 @@ func resolveGitIndexPath(workDir string) string { return indexPath } +// workDirHasOwnGitEntry returns true when workDir contains a `.git` entry of +// its own (file for worktrees, directory for plain repos). This is what +// makes a directory "a git repo" from a tracker's perspective — without it, +// git would ascend up the tree to the nearest ancestor `.git`, which is the +// wrong scope for nested layouts (e.g. a multi-repo task root sitting +// inside the developer's kandev checkout). +func workDirHasOwnGitEntry(workDir string) bool { + if workDir == "" { + return false + } + _, err := os.Lstat(filepath.Join(workDir, ".git")) + return err == nil +} + // workDirExists checks whether the workspace directory still exists on disk. // Used to detect deleted worktrees and stop polling loops gracefully. // The workDir is validated at construction time via resolveExistingWorkDir, diff --git a/apps/backend/internal/agentctl/server/process/workspace_tracker_multi_repo_test.go b/apps/backend/internal/agentctl/server/process/workspace_tracker_multi_repo_test.go new file mode 100644 index 0000000000..4ed9b9df49 --- /dev/null +++ b/apps/backend/internal/agentctl/server/process/workspace_tracker_multi_repo_test.go @@ -0,0 +1,73 @@ +package process + +import ( + "context" + "os" + "path/filepath" + "testing" +) + +// TestWorkspaceTracker_FallsBackToRepoSubdir verifies the fix for the +// "changes panel empty on multi-repo task" issue. When the agent's workspace +// path is the multi-repo task root (a plain holder directory), the workspace +// tracker should fall back to the first git-repo child so it can still emit +// git status updates for at least one repo. +func TestWorkspaceTracker_FallsBackToRepoSubdir(t *testing.T) { + repoDir, cleanup := setupTestRepo(t) + defer cleanup() + + // Build a synthetic task root that holds the test repo as a real + // sub-directory, matching what env_preparer creates for multi-repo tasks + // (worktrees placed under ~/.kandev/tasks/{TaskDirName}/{RepoName}/). + // We rename rather than symlink because the production layout uses real + // directories — and os.ReadDir's IsDir() returns false for symlinks. + taskRoot := t.TempDir() + repoSubdir := filepath.Join(taskRoot, "frontend") + if err := os.Rename(repoDir, repoSubdir); err != nil { + t.Fatalf("rename: %v", err) + } + + wt := NewWorkspaceTracker(taskRoot, newTestLogger(t)) + + // The tracker must have resolved its workDir to the repo subdir, otherwise + // getGitStatus would fail with "not a git repository". + if wt.workDir == taskRoot { + t.Fatalf("expected workDir to fall back to %q, got the bare task root %q", repoSubdir, taskRoot) + } + + status, err := wt.getGitStatus(context.Background()) + if err != nil { + t.Fatalf("getGitStatus on resolved subdir: %v", err) + } + if status.Branch == "" { + t.Errorf("expected non-empty branch from the fallback subdir; got status=%+v", status) + } +} + +// TestWorkspaceTracker_NoFallbackForRealRepo verifies the fallback only kicks +// in when the work dir itself is non-git. Single-repo tasks must keep their +// previous behavior verbatim. +func TestWorkspaceTracker_NoFallbackForRealRepo(t *testing.T) { + repoDir, cleanup := setupTestRepo(t) + defer cleanup() + + wt := NewWorkspaceTracker(repoDir, newTestLogger(t)) + if wt.workDir != repoDir { + t.Errorf("real git repo workDir should not be rewritten; got %q want %q", wt.workDir, repoDir) + } +} + +// TestWorkspaceTracker_NoFallbackWhenNoGitChild verifies that a truly empty +// task root (no git children) doesn't try to fall back to anything — the +// tracker keeps the original workDir and just emits no git data. +func TestWorkspaceTracker_NoFallbackWhenNoGitChild(t *testing.T) { + tmp := t.TempDir() + if err := os.MkdirAll(filepath.Join(tmp, "not-a-repo"), 0755); err != nil { + t.Fatalf("mkdir: %v", err) + } + + wt := NewWorkspaceTracker(tmp, newTestLogger(t)) + if wt.workDir != tmp { + t.Errorf("expected workDir unchanged when no git child exists; got %q", wt.workDir) + } +} diff --git a/apps/backend/internal/agentctl/types/streams/file.go b/apps/backend/internal/agentctl/types/streams/file.go index e156e2430a..078dc63435 100644 --- a/apps/backend/internal/agentctl/types/streams/file.go +++ b/apps/backend/internal/agentctl/types/streams/file.go @@ -31,6 +31,11 @@ type FileChangeNotification struct { // Timestamp is when the change was detected. Timestamp time.Time `json:"timestamp"` + // RepositoryName identifies which repository emitted this file change in + // multi-repo task workspaces. Empty for single-repo. Carried through to + // the frontend so per-repo views can scope refresh signals correctly. + RepositoryName string `json:"repository_name,omitempty"` + // Path is the file path relative to workspace root. Path string `json:"path"` @@ -45,6 +50,10 @@ type FileListUpdate struct { // Timestamp is when the listing was captured. Timestamp time.Time `json:"timestamp"` + // RepositoryName identifies which repository this list belongs to in + // multi-repo workspaces. Empty for single-repo. See GitStatusUpdate. + RepositoryName string `json:"repository_name,omitempty"` + // Files contains the list of files in the workspace. Files []FileEntry `json:"files"` } @@ -163,9 +172,14 @@ type FileSearchResponse struct { // // HTTP endpoint: POST /api/v1/workspace/file/content type FileUpdateRequest struct { - // Path is the file path (relative to workspace root). + // Path is the file path (relative to the per-repo subpath when Repo is + // set, otherwise relative to the workspace root). Path string `json:"path"` + // Repo is the multi-repo subpath (e.g. "kandev"); empty for single-repo + // workspaces. When set, Path is interpreted relative to /. + Repo string `json:"repo,omitempty"` + // Diff is the unified diff to apply to the file. Diff string `json:"diff"` @@ -205,8 +219,13 @@ type FileUpdateResponse struct { // // HTTP endpoint: POST /api/v1/workspace/file/create type FileCreateRequest struct { - // Path is the file path (relative to workspace root). + // Path is the file path (relative to the per-repo subpath when Repo is + // set, otherwise relative to the workspace root). Path string `json:"path"` + + // Repo is the multi-repo subpath (e.g. "kandev"); empty for single-repo + // workspaces. When set, Path is interpreted relative to /. + Repo string `json:"repo,omitempty"` } // FileCreateResponse represents a response to a file create request. @@ -248,11 +267,16 @@ type FileDeleteResponse struct { // // HTTP endpoint: POST /api/v1/workspace/file/rename type FileRenameRequest struct { - // OldPath is the current path (relative to workspace root). + // OldPath is the current path (relative to the per-repo subpath when + // Repo is set, otherwise relative to the workspace root). OldPath string `json:"old_path"` - // NewPath is the new path (relative to workspace root). + // NewPath is the new path (same scoping rules as OldPath). NewPath string `json:"new_path"` + + // Repo is the multi-repo subpath (e.g. "kandev"); empty for single-repo + // workspaces. Both OldPath and NewPath are scoped to this repo. + Repo string `json:"repo,omitempty"` } // FileRenameResponse represents a response to a rename request. diff --git a/apps/backend/internal/agentctl/types/streams/git.go b/apps/backend/internal/agentctl/types/streams/git.go index 1bd6e1cf7d..ed6284ea13 100644 --- a/apps/backend/internal/agentctl/types/streams/git.go +++ b/apps/backend/internal/agentctl/types/streams/git.go @@ -10,6 +10,12 @@ type GitStatusUpdate struct { // Timestamp is when this status was captured. Timestamp time.Time `json:"timestamp"` + // RepositoryName identifies which repository this status belongs to when + // the agent's workspace contains multiple git repos as siblings (multi-repo + // task roots). Empty for single-repo workspaces. Frontends key per-repo + // state on this name. + RepositoryName string `json:"repository_name,omitempty"` + // Modified contains paths of modified files. Modified []string `json:"modified"` @@ -89,6 +95,11 @@ type GitCommitNotification struct { // Timestamp is when this notification was created. Timestamp time.Time `json:"timestamp"` + // RepositoryName identifies which repository this commit belongs to in + // multi-repo task workspaces. Empty for single-repo. Carried through to + // the frontend so the Commits panel can render per-repo group headers. + RepositoryName string `json:"repository_name,omitempty"` + // CommitSHA is the SHA of the new commit. CommitSHA string `json:"commit_sha"` @@ -123,6 +134,11 @@ type GitResetNotification struct { // Timestamp is when this notification was created. Timestamp time.Time `json:"timestamp"` + // RepositoryName identifies which repository this reset belongs to in + // multi-repo task workspaces. Empty for single-repo. Carried through to + // the frontend so the Changes panel can scope per-repo group state. + RepositoryName string `json:"repository_name,omitempty"` + // PreviousHead is the SHA HEAD was pointing to before the reset. PreviousHead string `json:"previous_head"` @@ -136,6 +152,11 @@ type GitBranchSwitchNotification struct { // Timestamp is when this notification was created. Timestamp time.Time `json:"timestamp"` + // RepositoryName identifies which repository this branch switch belongs to + // in multi-repo task workspaces. Empty for single-repo. Carried through to + // the frontend so the Changes panel can update the matching per-repo group. + RepositoryName string `json:"repository_name,omitempty"` + // PreviousBranch is the branch name before the switch. PreviousBranch string `json:"previous_branch"` diff --git a/apps/backend/internal/github/handlers.go b/apps/backend/internal/github/handlers.go index d0b9a58e68..27b6d71c6b 100644 --- a/apps/backend/internal/github/handlers.go +++ b/apps/backend/internal/github/handlers.go @@ -216,9 +216,21 @@ func wsGetTaskPR(svc *Service, _ *logger.Logger) func(ctx context.Context, msg * } } +// wsSyncTaskPR returns ALL PR rows for a task — multi-repo tasks have one +// per repo. Single-repo callers can read `prs[0]` (or treat empty as "no +// PR yet"); multi-repo callers iterate and call setTaskPR for each so the +// per-repo PR icon stays in sync. The legacy single-PR shape would have +// silently dropped every repo's PR except the most-recently-updated one. func wsSyncTaskPR(svc *Service, _ *logger.Logger) func(ctx context.Context, msg *ws.Message) (*ws.Message, error) { return wsWithField("task_id", func(ctx context.Context, taskID string) (interface{}, error) { - return svc.TriggerPRSync(ctx, taskID) + prs, err := svc.TriggerPRSyncAll(ctx, taskID) + if err != nil { + return nil, err + } + // Return an envelope so the frontend always gets a deterministic + // shape even on empty results (`{prs: []}`); a bare `nil` would + // confuse the WS handler's success/error branching. + return map[string]interface{}{"prs": prs}, nil }) } diff --git a/apps/backend/internal/github/models.go b/apps/backend/internal/github/models.go index ce85317f4e..deea89d747 100644 --- a/apps/backend/internal/github/models.go +++ b/apps/backend/internal/github/models.go @@ -113,11 +113,14 @@ type IssueSearchPage struct { PerPage int `json:"per_page"` } -// PRWatch tracks active PR monitoring (session → PR). +// PRWatch tracks active PR monitoring (session → PR). RepositoryID identifies +// which task repository the watched PR belongs to (multi-repo support; empty +// for legacy rows). type PRWatch struct { ID string `json:"id" db:"id"` SessionID string `json:"session_id" db:"session_id"` TaskID string `json:"task_id" db:"task_id"` + RepositoryID string `json:"repository_id,omitempty" db:"repository_id"` Owner string `json:"owner" db:"owner"` Repo string `json:"repo" db:"repo"` PRNumber int `json:"pr_number" db:"pr_number"` @@ -130,10 +133,13 @@ type PRWatch struct { UpdatedAt time.Time `json:"updated_at" db:"updated_at"` } -// TaskPR associates a PR with a task. +// TaskPR associates a PR with a task. RepositoryID identifies which task +// repository this PR belongs to (multi-repo tasks can have one PR per repo). +// Empty for legacy rows persisted before multi-repo support. type TaskPR struct { ID string `json:"id" db:"id"` TaskID string `json:"task_id" db:"task_id"` + RepositoryID string `json:"repository_id,omitempty" db:"repository_id"` Owner string `json:"owner" db:"owner"` Repo string `json:"repo" db:"repo"` PRNumber int `json:"pr_number" db:"pr_number"` diff --git a/apps/backend/internal/github/poller.go b/apps/backend/internal/github/poller.go index 6d7a72cb8a..14e45a711e 100644 --- a/apps/backend/internal/github/poller.go +++ b/apps/backend/internal/github/poller.go @@ -19,12 +19,15 @@ const ( ) // TaskBranchInfo describes a task+session that may need a PR watch. +// RepositoryID scopes multi-repo tasks: each repo is a separate watch row. +// Empty for legacy single-repo tasks. type TaskBranchInfo struct { - TaskID string - SessionID string - Owner string - Repo string - Branch string + TaskID string + SessionID string + RepositoryID string + Owner string + Repo string + Branch string } // TaskBranchProvider lists tasks that should have PR watches and resolves branches. @@ -202,7 +205,7 @@ func (p *Poller) detectPRForWatch(ctx context.Context, watch *PRWatch) { return } - if _, assocErr := p.service.AssociatePRWithTask(ctx, watch.TaskID, pr); assocErr != nil { + if _, assocErr := p.service.AssociatePRWithTask(ctx, watch.TaskID, watch.RepositoryID, pr); assocErr != nil { p.logger.Error("failed to associate detected PR with task", zap.String("task_id", watch.TaskID), zap.Int("pr_number", pr.Number), @@ -258,7 +261,7 @@ func (p *Poller) reconcileWatches(ctx context.Context) { } for _, task := range tasks { if _, ensureErr := p.service.EnsurePRWatch( - ctx, task.SessionID, task.TaskID, task.Owner, task.Repo, task.Branch, + ctx, task.SessionID, task.TaskID, task.RepositoryID, task.Owner, task.Repo, task.Branch, ); ensureErr != nil { p.logger.Error("failed to ensure PR watch", zap.String("session_id", task.SessionID), zap.Error(ensureErr)) diff --git a/apps/backend/internal/github/service.go b/apps/backend/internal/github/service.go index 63e81e203b..02d25f713c 100644 --- a/apps/backend/internal/github/service.go +++ b/apps/backend/internal/github/service.go @@ -301,37 +301,59 @@ func (s *Service) SubmitReview(ctx context.Context, owner, repo string, number i // --- PR Watch operations --- -// CreatePRWatch creates a new PR watch for a session. -func (s *Service) CreatePRWatch(ctx context.Context, sessionID, taskID, owner, repo string, prNumber int, branch string) (*PRWatch, error) { - existing, err := s.store.GetPRWatchBySession(ctx, sessionID) +// CreatePRWatch creates a new PR watch for a (session, repository) pair. +// `repositoryID` may be empty for legacy single-repo callers; multi-repo +// callers must pass the per-task repository_id so each repo gets its own +// watch row. +func (s *Service) CreatePRWatch(ctx context.Context, sessionID, taskID, repositoryID, owner, repo string, prNumber int, branch string) (*PRWatch, error) { + existing, err := s.store.GetPRWatchBySessionAndRepo(ctx, sessionID, repositoryID) if err != nil { return nil, err } if existing != nil { - return existing, nil // already watching + return existing, nil // already watching this (session, repo) } w := &PRWatch{ - SessionID: sessionID, - TaskID: taskID, - Owner: owner, - Repo: repo, - PRNumber: prNumber, - Branch: branch, + SessionID: sessionID, + TaskID: taskID, + RepositoryID: repositoryID, + Owner: owner, + Repo: repo, + PRNumber: prNumber, + Branch: branch, } if err := s.store.CreatePRWatch(ctx, w); err != nil { return nil, fmt.Errorf("create PR watch: %w", err) } s.logger.Info("created PR watch", zap.String("session_id", sessionID), + zap.String("repository_id", repositoryID), zap.Int("pr_number", prNumber)) return w, nil } -// GetPRWatchBySession returns the PR watch for a session. +// GetPRWatchBySession returns the first PR watch for a session. Multi-repo +// callers should prefer GetPRWatchBySessionAndRepo to avoid landing on the +// wrong repo's watch. func (s *Service) GetPRWatchBySession(ctx context.Context, sessionID string) (*PRWatch, error) { return s.store.GetPRWatchBySession(ctx, sessionID) } +// GetPRWatchBySessionAndRepo returns the PR watch for a (session, repo) pair. +func (s *Service) GetPRWatchBySessionAndRepo(ctx context.Context, sessionID, repositoryID string) (*PRWatch, error) { + return s.store.GetPRWatchBySessionAndRepo(ctx, sessionID, repositoryID) +} + +// ListPRWatchesBySession returns every PR watch for a session. +func (s *Service) ListPRWatchesBySession(ctx context.Context, sessionID string) ([]*PRWatch, error) { + return s.store.ListPRWatchesBySession(ctx, sessionID) +} + +// ListPRWatchesByTask returns every PR watch for a task. +func (s *Service) ListPRWatchesByTask(ctx context.Context, taskID string) ([]*PRWatch, error) { + return s.store.ListPRWatchesByTask(ctx, taskID) +} + // ListActivePRWatches returns all active PR watches. func (s *Service) ListActivePRWatches(ctx context.Context) ([]*PRWatch, error) { return s.store.ListActivePRWatches(ctx) @@ -387,10 +409,14 @@ func (s *Service) CheckPRWatch(ctx context.Context, watch *PRWatch) (*PRStatus, return status, hasNew, nil } -// EnsurePRWatch creates a PRWatch with pr_number=0 for a session if one doesn't already exist. -// The poller will detect the PR by searching for the branch on GitHub. -func (s *Service) EnsurePRWatch(ctx context.Context, sessionID, taskID, owner, repo, branch string) (*PRWatch, error) { - existing, err := s.store.GetPRWatchBySession(ctx, sessionID) +// EnsurePRWatch creates a PRWatch with pr_number=0 for a (session, repo) pair +// if one doesn't already exist. The poller will detect the PR by searching +// for the branch on GitHub. `repositoryID` is empty for legacy single-repo +// callers; multi-repo callers MUST pass the per-task repository_id so each +// repo gets its own watch (the table's UNIQUE(session_id, repository_id) used +// to be UNIQUE(session_id), which silently dropped second-repo watches). +func (s *Service) EnsurePRWatch(ctx context.Context, sessionID, taskID, repositoryID, owner, repo, branch string) (*PRWatch, error) { + existing, err := s.store.GetPRWatchBySessionAndRepo(ctx, sessionID, repositoryID) if err != nil { return nil, err } @@ -398,27 +424,36 @@ func (s *Service) EnsurePRWatch(ctx context.Context, sessionID, taskID, owner, r return existing, nil } w := &PRWatch{ - SessionID: sessionID, - TaskID: taskID, - Owner: owner, - Repo: repo, - PRNumber: 0, - Branch: branch, + SessionID: sessionID, + TaskID: taskID, + RepositoryID: repositoryID, + Owner: owner, + Repo: repo, + PRNumber: 0, + Branch: branch, } if err := s.store.CreatePRWatch(ctx, w); err != nil { return nil, fmt.Errorf("ensure PR watch: %w", err) } s.logger.Info("created PR watch for session (will search for PR)", zap.String("session_id", sessionID), + zap.String("repository_id", repositoryID), zap.String("branch", branch)) return w, nil } // --- Task-PR association --- -// AssociatePRWithTask creates a task-PR association. -func (s *Service) AssociatePRWithTask(ctx context.Context, taskID string, pr *PR) (*TaskPR, error) { - existing, err := s.store.GetTaskPR(ctx, taskID) +// AssociatePRWithTask creates a task-PR association scoped to a specific +// repository. `repositoryID` is the per-task repository_id (from +// task_repositories); empty preserves legacy single-repo behavior. Multi-repo +// callers MUST pass it — empty causes ReplaceTaskPR to wipe the entire task's +// PR rows (legacy "delete all" branch), which is what older code relied on. +func (s *Service) AssociatePRWithTask(ctx context.Context, taskID, repositoryID string, pr *PR) (*TaskPR, error) { + // Check for an existing PR for this exact (task, repo). Multi-repo callers + // must scope by repository_id so the same PR number in two repos doesn't + // short-circuit the second association. + existing, err := s.store.GetTaskPRByRepository(ctx, taskID, repositoryID) if err != nil { return nil, err } @@ -426,32 +461,34 @@ func (s *Service) AssociatePRWithTask(ctx context.Context, taskID string, pr *PR return existing, nil } tp := &TaskPR{ - TaskID: taskID, - Owner: pr.RepoOwner, - Repo: pr.RepoName, - PRNumber: pr.Number, - PRURL: pr.HTMLURL, - PRTitle: pr.Title, - HeadBranch: pr.HeadBranch, - BaseBranch: pr.BaseBranch, - AuthorLogin: pr.AuthorLogin, - State: pr.State, - Additions: pr.Additions, - Deletions: pr.Deletions, - CreatedAt: pr.CreatedAt, - MergedAt: pr.MergedAt, - ClosedAt: pr.ClosedAt, + TaskID: taskID, + RepositoryID: repositoryID, + Owner: pr.RepoOwner, + Repo: pr.RepoName, + PRNumber: pr.Number, + PRURL: pr.HTMLURL, + PRTitle: pr.Title, + HeadBranch: pr.HeadBranch, + BaseBranch: pr.BaseBranch, + AuthorLogin: pr.AuthorLogin, + State: pr.State, + Additions: pr.Additions, + Deletions: pr.Deletions, + CreatedAt: pr.CreatedAt, + MergedAt: pr.MergedAt, + ClosedAt: pr.ClosedAt, } - // ReplaceTaskPR atomically deletes any existing association for the task - // and inserts the new row inside a single transaction. This preserves the - // effective 1:1 task→PR mapping and prevents a window where the task has - // no associated PR or concurrent calls produce duplicate rows. + // ReplaceTaskPR atomically deletes any existing association for the + // (task, repository) pair and inserts the new row inside one transaction. + // Scoping by repository_id keeps multi-repo tasks intact; legacy callers + // (repositoryID == "") still get the "delete all" semantics. if err := s.store.ReplaceTaskPR(ctx, tp); err != nil { return nil, fmt.Errorf("replace task PR: %w", err) } if existing != nil { s.logger.Info("replaced stale task PR association", zap.String("task_id", taskID), + zap.String("repository_id", repositoryID), zap.Int("old_pr_number", existing.PRNumber), zap.Int("new_pr_number", pr.Number)) } @@ -466,13 +503,18 @@ func (s *Service) AssociatePRWithTask(ctx context.Context, taskID string, pr *PR s.logger.Info("associated PR with task", zap.String("task_id", taskID), + zap.String("repository_id", repositoryID), zap.Int("pr_number", pr.Number)) return tp, nil } -// AssociatePRByURL parses a GitHub PR URL, fetches the PR data, creates a PR watch, -// and associates it with the given task. Called after user creates a PR from the UI. -func (s *Service) AssociatePRByURL(ctx context.Context, sessionID, taskID, prURL, branch string) { +// AssociatePRByURL parses a GitHub PR URL, fetches the PR data, creates a PR +// watch, and associates it with the given task. Called after the user +// creates a PR from the UI. `repositoryID` scopes the watch + association to +// a specific per-task repository (multi-repo tasks); empty preserves the +// legacy single-repo behavior. Without this, the second repo's UI-initiated +// PR would overwrite the first's TaskPR row. +func (s *Service) AssociatePRByURL(ctx context.Context, sessionID, taskID, repositoryID, prURL, branch string) { if s.client == nil { return } @@ -493,13 +535,13 @@ func (s *Service) AssociatePRByURL(ctx context.Context, sessionID, taskID, prURL if branch == "" { branch = pr.HeadBranch } - if _, watchErr := s.CreatePRWatch(ctx, sessionID, taskID, owner, repo, prNumber, branch); watchErr != nil { + if _, watchErr := s.CreatePRWatch(ctx, sessionID, taskID, repositoryID, owner, repo, prNumber, branch); watchErr != nil { s.logger.Error("failed to create PR watch after PR creation", zap.String("session_id", sessionID), zap.Error(watchErr)) } // Associate PR with task (persists + publishes WS event) - if _, assocErr := s.AssociatePRWithTask(ctx, taskID, pr); assocErr != nil { + if _, assocErr := s.AssociatePRWithTask(ctx, taskID, repositoryID, pr); assocErr != nil { s.logger.Error("failed to associate PR with task after creation", zap.String("task_id", taskID), zap.Error(assocErr)) } @@ -549,26 +591,37 @@ func (s *Service) GetTaskPR(ctx context.Context, taskID string) (*TaskPR, error) return s.store.GetTaskPR(ctx, taskID) } -// ListTaskPRs returns PR associations for multiple tasks. -func (s *Service) ListTaskPRs(ctx context.Context, taskIDs []string) (map[string]*TaskPR, error) { +// ListTaskPRs returns PR associations for multiple tasks, grouped by task_id. +// Multi-repo tasks may have more than one PR per task. +func (s *Service) ListTaskPRs(ctx context.Context, taskIDs []string) (map[string][]*TaskPR, error) { return s.store.ListTaskPRsByTaskIDs(ctx, taskIDs) } -// ListWorkspaceTaskPRs returns all PR associations for a workspace. -// It returns cached data immediately and triggers background refresh for stale entries. -func (s *Service) ListWorkspaceTaskPRs(ctx context.Context, workspaceID string) (map[string]*TaskPR, error) { +// ListWorkspaceTaskPRs returns all PR associations for a workspace, grouped by +// task_id. Multi-repo tasks may have more than one PR per task. It returns +// cached data immediately and triggers background refresh for stale entries. +func (s *Service) ListWorkspaceTaskPRs(ctx context.Context, workspaceID string) (map[string][]*TaskPR, error) { result, err := s.store.ListTaskPRsByWorkspaceID(ctx, workspaceID) if err != nil { return nil, err } - // Collect stale task IDs for background refresh - var staleTaskIDs []string - for _, tp := range result { - if tp.LastSyncedAt == nil || time.Since(*tp.LastSyncedAt) >= prSyncFreshnessWindow { - staleTaskIDs = append(staleTaskIDs, tp.TaskID) + // Collect stale task IDs for background refresh. A task is considered stale + // if any of its PRs are stale; the sync is per-task so we only need to + // queue each task once. + staleTasks := make(map[string]struct{}) + for taskID, prs := range result { + for _, tp := range prs { + if tp.LastSyncedAt == nil || time.Since(*tp.LastSyncedAt) >= prSyncFreshnessWindow { + staleTasks[taskID] = struct{}{} + break + } } } + staleTaskIDs := make([]string, 0, len(staleTasks)) + for id := range staleTasks { + staleTaskIDs = append(staleTaskIDs, id) + } // Background refresh with bounded concurrency if len(staleTaskIDs) > 0 { @@ -580,7 +633,7 @@ func (s *Service) ListWorkspaceTaskPRs(ctx context.Context, workspaceID string) defer func() { <-sem }() syncCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() - if _, syncErr := s.TriggerPRSync(syncCtx, id); syncErr != nil { + if _, syncErr := s.TriggerPRSyncAll(syncCtx, id); syncErr != nil { s.logger.Debug("background PR sync failed", zap.String("task_id", id), zap.Error(syncErr)) } }(taskID) @@ -591,14 +644,35 @@ func (s *Service) ListWorkspaceTaskPRs(ctx context.Context, workspaceID string) return result, nil } -// SyncTaskPR updates a TaskPR record with the latest PR status. +// findTaskPRForStatus locates the TaskPR row matching the (task, owner, repo, +// pr_number) tuple from a poll result. Multi-repo tasks can have multiple +// rows for the same task — narrowing by (owner, repo, pr_number) ensures the +// caller updates the right one. Returns nil (no error) when no row exists, +// matching the prior GetTaskPR semantics. +func (s *Service) findTaskPRForStatus(ctx context.Context, taskID string, pr *PR) (*TaskPR, error) { + rows, err := s.store.ListTaskPRsByTask(ctx, taskID) + if err != nil { + return nil, err + } + for _, tp := range rows { + if tp.Owner == pr.RepoOwner && tp.Repo == pr.RepoName && tp.PRNumber == pr.Number { + return tp, nil + } + } + return nil, nil +} + +// SyncTaskPR updates a TaskPR record with the latest PR status. Multi-repo: +// the row is found by (task_id, owner, repo, pr_number) since the same +// task can have several PRs; the legacy GetTaskPR(taskID) "first match" +// would cross repos and silently update the wrong row. // It only publishes a github.task_pr.updated event when data actually changed, // preventing feedback loops with frontend sync handlers. func (s *Service) SyncTaskPR(ctx context.Context, taskID string, status *PRStatus) error { if status == nil || status.PR == nil { return fmt.Errorf("sync task PR: missing PR data for task %s", taskID) } - tp, err := s.store.GetTaskPR(ctx, taskID) + tp, err := s.findTaskPRForStatus(ctx, taskID, status.PR) if err != nil || tp == nil { return err } @@ -749,10 +823,10 @@ func (s *Service) GetPRStatusesBatch(ctx context.Context, refs []PRRef) (map[str return result, nil } -// TriggerPRSync performs an immediate PR status sync for a task. -// If the watch has a PR number, it fetches the latest status from GitHub -// and syncs it to the TaskPR record. If still searching (pr_number=0), -// it attempts to find the PR by branch. +// TriggerPRSync performs an immediate PR status sync for a task. Single-repo +// callers see the same single-PR contract as before. Multi-repo callers get +// the primary repo's PR back; they should use TriggerPRSyncAll to refresh +// every repo's PR in one round-trip. func (s *Service) TriggerPRSync(ctx context.Context, taskID string) (*TaskPR, error) { watch, err := s.store.GetPRWatchByTask(ctx, taskID) if err != nil { @@ -770,6 +844,51 @@ func (s *Service) TriggerPRSync(ctx context.Context, taskID string) (*TaskPR, er return s.triggerPRStatusSync(ctx, watch, taskID) } +// TriggerPRSyncAll performs an immediate PR status sync for every PR watch +// associated with the task and returns every resulting TaskPR. For +// multi-repo tasks this is the right entry point — TriggerPRSync only +// touches the most recently updated watch and silently leaves the other +// repos' PRs stale. Returns an empty slice (not nil) when the task has no +// watches. +func (s *Service) TriggerPRSyncAll(ctx context.Context, taskID string) ([]*TaskPR, error) { + watches, err := s.store.ListPRWatchesByTask(ctx, taskID) + if err != nil { + return nil, fmt.Errorf("list PR watches: %w", err) + } + if len(watches) == 0 { + // No watches — fall back to whatever TaskPRs already exist (e.g. + // PRs imported via task-create-from-PR-URL where the watch is + // optional). Empty slice if none. + existing, listErr := s.store.ListTaskPRsByTask(ctx, taskID) + if listErr != nil { + return nil, fmt.Errorf("list task PRs: %w", listErr) + } + return existing, nil + } + results := make([]*TaskPR, 0, len(watches)) + for _, w := range watches { + var tp *TaskPR + var syncErr error + if w.PRNumber == 0 { + tp, syncErr = s.triggerPRDetection(ctx, w, taskID) + } else { + tp, syncErr = s.triggerPRStatusSync(ctx, w, taskID) + } + if syncErr != nil { + s.logger.Warn("per-repo PR sync failed", + zap.String("task_id", taskID), + zap.String("repository_id", w.RepositoryID), + zap.Int("pr_number", w.PRNumber), + zap.Error(syncErr)) + continue + } + if tp != nil { + results = append(results, tp) + } + } + return results, nil +} + func (s *Service) triggerPRDetection(ctx context.Context, watch *PRWatch, taskID string) (*TaskPR, error) { if s.client == nil { return nil, nil @@ -783,7 +902,7 @@ func (s *Service) triggerPRDetection(ctx context.Context, watch *PRWatch, taskID zap.String("watch_id", watch.ID), zap.Int("pr_number", pr.Number), zap.Error(err)) return nil, fmt.Errorf("update PR watch: %w", err) } - if _, assocErr := s.AssociatePRWithTask(ctx, taskID, pr); assocErr != nil { + if _, assocErr := s.AssociatePRWithTask(ctx, taskID, watch.RepositoryID, pr); assocErr != nil { s.logger.Error("failed to associate PR with task during sync", zap.String("task_id", taskID), zap.Int("pr_number", pr.Number), zap.Error(assocErr)) return nil, fmt.Errorf("associate PR: %w", assocErr) @@ -794,8 +913,23 @@ func (s *Service) triggerPRDetection(ctx context.Context, watch *PRWatch, taskID } func (s *Service) triggerPRStatusSync(ctx context.Context, watch *PRWatch, taskID string) (*TaskPR, error) { - // Freshness check: skip GitHub API if recently synced - if tp, _ := s.store.GetTaskPR(ctx, taskID); tp != nil && tp.LastSyncedAt != nil { + // Freshness check: skip GitHub API if recently synced. Look up by the + // watch's own (task, repo) — the legacy GetTaskPR(ctx, taskID) returned + // "first match" which for multi-repo tasks would mistakenly hit the + // other repo's row and skip the sync that this watch actually needs. + loadTaskPR := func(c context.Context) (*TaskPR, error) { + tp, err := s.store.GetTaskPRByRepository(c, taskID, watch.RepositoryID) + if err != nil { + return nil, err + } + if tp != nil { + return tp, nil + } + // Fall back to the legacy untagged row for single-repo tasks that + // haven't been re-associated under the multi-repo schema yet. + return s.store.GetTaskPR(c, taskID) + } + if tp, _ := loadTaskPR(ctx); tp != nil && tp.LastSyncedAt != nil { if time.Since(*tp.LastSyncedAt) < prSyncFreshnessWindow { return tp, nil } @@ -811,12 +945,12 @@ func (s *Service) triggerPRStatusSync(ctx context.Context, watch *PRWatch, taskI return nil, checkErr } if status == nil { - return s.store.GetTaskPR(bgCtx, taskID) + return loadTaskPR(bgCtx) } if syncErr := s.SyncTaskPR(bgCtx, taskID, status); syncErr != nil { return nil, syncErr } - return s.store.GetTaskPR(bgCtx, taskID) + return loadTaskPR(bgCtx) }) if err != nil { return nil, err diff --git a/apps/backend/internal/github/service_review_cleanup_test.go b/apps/backend/internal/github/service_review_cleanup_test.go index eaa8227745..07d1094cfd 100644 --- a/apps/backend/internal/github/service_review_cleanup_test.go +++ b/apps/backend/internal/github/service_review_cleanup_test.go @@ -108,7 +108,7 @@ func TestAssociatePRWithTask_ReplacesStaleAssociation(t *testing.T) { RepoName: "repo", AuthorLogin: "alice", } - tp, err := svc.AssociatePRWithTask(ctx, "t1", newPR) + tp, err := svc.AssociatePRWithTask(ctx, "t1", "", newPR) if err != nil { t.Fatalf("AssociatePRWithTask: %v", err) } diff --git a/apps/backend/internal/github/store.go b/apps/backend/internal/github/store.go index c0ae8fea6f..3a852f0284 100644 --- a/apps/backend/internal/github/store.go +++ b/apps/backend/internal/github/store.go @@ -7,6 +7,7 @@ import ( "errors" "fmt" "os" + "strings" "time" "github.com/google/uuid" @@ -29,11 +30,19 @@ func NewStore(writer, reader *sqlx.DB) (*Store, error) { } // createTablesSQL holds the DDL for all GitHub integration tables. +// +// Multi-repo: both `github_pr_watches` and `github_task_prs` carry a +// `repository_id` that names the per-task repository (when the task spans +// multiple repos). The uniqueness constraints include `repository_id` so +// each repo can have its own watch/PR for the same session/task. Existing +// installs migrated from the single-repo schema get the column dropped to +// `”` (empty) and the constraints rebuilt by `migratePRTablesForMultiRepo`. const createTablesSQL = ` CREATE TABLE IF NOT EXISTS github_pr_watches ( id TEXT PRIMARY KEY, - session_id TEXT NOT NULL UNIQUE, + session_id TEXT NOT NULL, task_id TEXT NOT NULL, + repository_id TEXT NOT NULL DEFAULT '', owner TEXT NOT NULL, repo TEXT NOT NULL, pr_number INTEGER NOT NULL, @@ -43,12 +52,14 @@ const createTablesSQL = ` last_check_status TEXT DEFAULT '', last_review_state TEXT DEFAULT '', created_at DATETIME NOT NULL, - updated_at DATETIME NOT NULL + updated_at DATETIME NOT NULL, + UNIQUE(session_id, repository_id) ); CREATE TABLE IF NOT EXISTS github_task_prs ( id TEXT PRIMARY KEY, task_id TEXT NOT NULL, + repository_id TEXT NOT NULL DEFAULT '', owner TEXT NOT NULL, repo TEXT NOT NULL, pr_number INTEGER NOT NULL, @@ -71,7 +82,7 @@ const createTablesSQL = ` closed_at DATETIME, last_synced_at DATETIME, updated_at DATETIME NOT NULL, - UNIQUE(task_id, pr_number) + UNIQUE(task_id, repository_id, pr_number) ); CREATE TABLE IF NOT EXISTS github_review_watches ( @@ -149,9 +160,210 @@ func (s *Store) initSchema() error { // Idempotent migrations for existing databases. _, _ = s.db.Exec(`ALTER TABLE github_pr_watches ADD COLUMN last_review_state TEXT DEFAULT ''`) _, _ = s.db.Exec(`ALTER TABLE github_task_prs ADD COLUMN mergeable_state TEXT NOT NULL DEFAULT ''`) + // Phase 4 (multi-repo): per-repo PR association on github_task_prs. + _, _ = s.db.Exec(`ALTER TABLE github_task_prs ADD COLUMN repository_id TEXT NOT NULL DEFAULT ''`) + _, _ = s.db.Exec(`ALTER TABLE github_pr_watches ADD COLUMN repository_id TEXT NOT NULL DEFAULT ''`) + if err := s.migratePRTablesForMultiRepo(); err != nil { + return fmt.Errorf("migrate PR tables for multi-repo: %w", err) + } + if err := s.backfillTaskPRsRepositoryID(); err != nil { + return fmt.Errorf("backfill github_task_prs.repository_id: %w", err) + } return nil } +// backfillTaskPRsRepositoryID heals github_task_prs rows that pre-date the +// per-repo schema (i.e. have repository_id = ”). Two passes: +// +// 1. Dedup: for each (task_id, owner, repo, pr_number) tuple, if a legacy +// row (repository_id = ”) AND a newer per-repo row coexist, drop the +// legacy one. This happens when an old single-repo install upgraded and +// a subsequent sync inserted a new row under the resolved repository_id +// instead of updating the legacy row, leaving two entries for one PR +// (and so two badges on the kanban card). +// +// 2. Backfill: for any remaining empty-repo rows, look up the task's +// primary repository (from `task_repositories` ordered by position) and +// stamp its id. Skipped silently when the task has zero per-repo rows +// (e.g. quick-chat tasks) — the row keeps its empty repository_id. +// +// Idempotent: re-running on a healed db is a no-op. +func (s *Store) backfillTaskPRsRepositoryID() error { + if _, err := s.db.Exec(` + DELETE FROM github_task_prs + WHERE repository_id = '' + AND EXISTS ( + SELECT 1 FROM github_task_prs other + WHERE other.task_id = github_task_prs.task_id + AND other.owner = github_task_prs.owner + AND other.repo = github_task_prs.repo + AND other.pr_number = github_task_prs.pr_number + AND other.repository_id != '' + ) + `); err != nil { + return fmt.Errorf("dedup legacy task PR rows: %w", err) + } + // `task_repositories` lives in the task package's schema, not ours. + // Skip the backfill when it doesn't exist (e.g. github-store unit tests + // that init only this package's schema). The dedup pass above is the + // load-bearing fix for the "two PRs on a single-repo task" symptom; the + // backfill is a courtesy that converts orphan legacy rows in real + // deployments. + if !s.tableExists("task_repositories") { + return nil + } + _, err := s.db.Exec(` + UPDATE github_task_prs + SET repository_id = ( + SELECT tr.repository_id + FROM task_repositories tr + WHERE tr.task_id = github_task_prs.task_id + ORDER BY tr.position + LIMIT 1 + ) + WHERE repository_id = '' + AND EXISTS ( + SELECT 1 FROM task_repositories tr + WHERE tr.task_id = github_task_prs.task_id + ) + `) + if err != nil { + return fmt.Errorf("backfill task PR repository_id: %w", err) + } + return nil +} + +// tableExists returns true when the named table is present in sqlite_master. +// Used by the multi-repo backfill to skip cross-package healing in unit +// tests that don't bring up the task schema. +func (s *Store) tableExists(name string) bool { + var n int + err := s.db.QueryRow(`SELECT 1 FROM sqlite_master WHERE type='table' AND name=?`, name).Scan(&n) + return err == nil +} + +// migratePRTablesForMultiRepo rebuilds `github_pr_watches` and +// `github_task_prs` to drop the legacy single-repo unique constraints +// (`UNIQUE(session_id)` and `UNIQUE(task_id, pr_number)`) and replace them +// with the multi-repo variants. SQLite can't ALTER TABLE DROP CONSTRAINT, so +// each table is rebuilt via the recommended copy-and-rename pattern. The +// migration is idempotent: it inspects `sqlite_master.sql` for the legacy +// constraint string and only runs the rebuild when found. +func (s *Store) migratePRTablesForMultiRepo() error { + if err := s.rebuildIfHasLegacyConstraint( + "github_pr_watches", + "session_id TEXT NOT NULL UNIQUE", + `CREATE TABLE github_pr_watches_new ( + id TEXT PRIMARY KEY, + session_id TEXT NOT NULL, + task_id TEXT NOT NULL, + repository_id TEXT NOT NULL DEFAULT '', + owner TEXT NOT NULL, + repo TEXT NOT NULL, + pr_number INTEGER NOT NULL, + branch TEXT NOT NULL, + last_checked_at DATETIME, + last_comment_at DATETIME, + last_check_status TEXT DEFAULT '', + last_review_state TEXT DEFAULT '', + created_at DATETIME NOT NULL, + updated_at DATETIME NOT NULL, + UNIQUE(session_id, repository_id) + )`, + `INSERT INTO github_pr_watches_new ( + id, session_id, task_id, repository_id, owner, repo, pr_number, branch, + last_checked_at, last_comment_at, last_check_status, last_review_state, + created_at, updated_at + ) SELECT + id, session_id, task_id, COALESCE(repository_id, ''), owner, repo, pr_number, branch, + last_checked_at, last_comment_at, last_check_status, last_review_state, + created_at, updated_at + FROM github_pr_watches`, + ); err != nil { + return err + } + return s.rebuildIfHasLegacyConstraint( + "github_task_prs", + "UNIQUE(task_id, pr_number)", + `CREATE TABLE github_task_prs_new ( + id TEXT PRIMARY KEY, + task_id TEXT NOT NULL, + repository_id TEXT NOT NULL DEFAULT '', + owner TEXT NOT NULL, + repo TEXT NOT NULL, + pr_number INTEGER NOT NULL, + pr_url TEXT NOT NULL, + pr_title TEXT NOT NULL, + head_branch TEXT NOT NULL, + base_branch TEXT NOT NULL, + author_login TEXT NOT NULL, + state TEXT NOT NULL DEFAULT 'open', + review_state TEXT NOT NULL DEFAULT '', + checks_state TEXT NOT NULL DEFAULT '', + mergeable_state TEXT NOT NULL DEFAULT '', + review_count INTEGER DEFAULT 0, + pending_review_count INTEGER DEFAULT 0, + comment_count INTEGER DEFAULT 0, + additions INTEGER DEFAULT 0, + deletions INTEGER DEFAULT 0, + created_at DATETIME NOT NULL, + merged_at DATETIME, + closed_at DATETIME, + last_synced_at DATETIME, + updated_at DATETIME NOT NULL, + UNIQUE(task_id, repository_id, pr_number) + )`, + `INSERT INTO github_task_prs_new ( + id, task_id, repository_id, owner, repo, pr_number, pr_url, pr_title, + head_branch, base_branch, author_login, state, review_state, checks_state, + mergeable_state, review_count, pending_review_count, comment_count, + additions, deletions, created_at, merged_at, closed_at, last_synced_at, updated_at + ) SELECT + id, task_id, COALESCE(repository_id, ''), owner, repo, pr_number, pr_url, pr_title, + head_branch, base_branch, author_login, state, review_state, checks_state, + mergeable_state, review_count, pending_review_count, comment_count, + additions, deletions, created_at, merged_at, closed_at, last_synced_at, updated_at + FROM github_task_prs`, + ) +} + +// rebuildIfHasLegacyConstraint checks the table's stored CREATE statement in +// `sqlite_master` for the literal `legacyConstraint` substring; if present, +// runs the table rebuild (create new, copy data, drop old, rename) inside a +// transaction. No-op when the legacy substring is absent — fresh installs +// already use the new schema and previously-migrated databases skip too. +func (s *Store) rebuildIfHasLegacyConstraint(table, legacyConstraint, createNew, copyData string) error { + var existingSQL string + row := s.db.QueryRow(`SELECT sql FROM sqlite_master WHERE type='table' AND name=?`, table) + if err := row.Scan(&existingSQL); err != nil { + // Table missing entirely shouldn't happen after createTablesSQL ran; + // treat as no-op to keep the migration robust under unexpected drift. + if errors.Is(err, sql.ErrNoRows) { + return nil + } + return err + } + if !strings.Contains(existingSQL, legacyConstraint) { + return nil + } + tx, err := s.db.Begin() + if err != nil { + return err + } + defer func() { _ = tx.Rollback() }() + for _, stmt := range []string{ + createNew, + copyData, + "DROP TABLE " + table, + "ALTER TABLE " + table + "_new RENAME TO " + table, + } { + if _, err := tx.Exec(stmt); err != nil { + return fmt.Errorf("rebuild %s: %w", table, err) + } + } + return tx.Commit() +} + // --- PR Watch operations --- // CreatePRWatch creates a new PR watch. @@ -163,23 +375,51 @@ func (s *Store) CreatePRWatch(ctx context.Context, w *PRWatch) error { w.CreatedAt = now w.UpdatedAt = now _, err := s.db.ExecContext(ctx, ` - INSERT INTO github_pr_watches (id, session_id, task_id, owner, repo, pr_number, branch, last_check_status, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - w.ID, w.SessionID, w.TaskID, w.Owner, w.Repo, w.PRNumber, w.Branch, w.LastCheckStatus, w.CreatedAt, w.UpdatedAt) + INSERT INTO github_pr_watches (id, session_id, task_id, repository_id, owner, repo, pr_number, branch, last_check_status, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + w.ID, w.SessionID, w.TaskID, w.RepositoryID, w.Owner, w.Repo, w.PRNumber, w.Branch, w.LastCheckStatus, w.CreatedAt, w.UpdatedAt) return err } -// GetPRWatchBySession returns the PR watch for a session. +// GetPRWatchBySession returns the first PR watch for a session. For +// multi-repo sessions the result is non-deterministic across repos — use +// GetPRWatchBySessionAndRepo or ListPRWatchesBySession instead. func (s *Store) GetPRWatchBySession(ctx context.Context, sessionID string) (*PRWatch, error) { var w PRWatch - err := s.ro.GetContext(ctx, &w, `SELECT * FROM github_pr_watches WHERE session_id = ?`, sessionID) + err := s.ro.GetContext(ctx, &w, + `SELECT * FROM github_pr_watches WHERE session_id = ? LIMIT 1`, sessionID) if errors.Is(err, sql.ErrNoRows) { return nil, nil } return &w, err } -// GetPRWatchByTask returns the PR watch for a task (first match). +// GetPRWatchBySessionAndRepo returns the PR watch for a (session, repository) +// pair, or nil. Used by per-repo branch-switch / commit handlers so each +// repo's watch is reset independently. +func (s *Store) GetPRWatchBySessionAndRepo(ctx context.Context, sessionID, repositoryID string) (*PRWatch, error) { + var w PRWatch + err := s.ro.GetContext(ctx, &w, + `SELECT * FROM github_pr_watches WHERE session_id = ? AND repository_id = ? LIMIT 1`, + sessionID, repositoryID) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + return &w, err +} + +// ListPRWatchesBySession returns every PR watch for a session (one per repo +// in multi-repo workspaces). Empty slice when no watches exist. +func (s *Store) ListPRWatchesBySession(ctx context.Context, sessionID string) ([]*PRWatch, error) { + var watches []*PRWatch + err := s.ro.SelectContext(ctx, &watches, + `SELECT * FROM github_pr_watches WHERE session_id = ? ORDER BY created_at ASC`, sessionID) + return watches, err +} + +// GetPRWatchByTask returns the first PR watch for a task. For multi-repo +// tasks the result is non-deterministic across repos — use +// ListPRWatchesByTask when every repo's watch is needed. func (s *Store) GetPRWatchByTask(ctx context.Context, taskID string) (*PRWatch, error) { var w PRWatch err := s.ro.GetContext(ctx, &w, `SELECT * FROM github_pr_watches WHERE task_id = ? ORDER BY updated_at DESC LIMIT 1`, taskID) @@ -189,6 +429,15 @@ func (s *Store) GetPRWatchByTask(ctx context.Context, taskID string) (*PRWatch, return &w, err } +// ListPRWatchesByTask returns every PR watch for a task (one per repo in +// multi-repo workspaces). +func (s *Store) ListPRWatchesByTask(ctx context.Context, taskID string) ([]*PRWatch, error) { + var watches []*PRWatch + err := s.ro.SelectContext(ctx, &watches, + `SELECT * FROM github_pr_watches WHERE task_id = ? ORDER BY created_at ASC`, taskID) + return watches, err +} + // ListActivePRWatches returns all active PR watches whose task is not archived. // Watches for archived tasks (and orphaned watches whose task row was hard-deleted) // are excluded so the poller stops making GitHub API calls for them. An INNER JOIN @@ -263,7 +512,9 @@ func (s *Store) UpdatePRWatchBranchIfSearching(ctx context.Context, id, branch s // --- TaskPR operations --- -// CreateTaskPR associates a PR with a task. +// CreateTaskPR associates a PR with a task. RepositoryID may be empty for +// single-repo tasks; multi-repo task launches set it so each repo's PR is +// distinguishable. func (s *Store) CreateTaskPR(ctx context.Context, tp *TaskPR) error { if tp.ID == "" { tp.ID = uuid.New().String() @@ -271,17 +522,18 @@ func (s *Store) CreateTaskPR(ctx context.Context, tp *TaskPR) error { now := time.Now().UTC() tp.UpdatedAt = now _, err := s.db.ExecContext(ctx, ` - INSERT INTO github_task_prs (id, task_id, owner, repo, pr_number, pr_url, pr_title, head_branch, base_branch, author_login, + INSERT INTO github_task_prs (id, task_id, repository_id, owner, repo, pr_number, pr_url, pr_title, head_branch, base_branch, author_login, state, review_state, checks_state, mergeable_state, review_count, pending_review_count, comment_count, additions, deletions, created_at, merged_at, closed_at, last_synced_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - tp.ID, tp.TaskID, tp.Owner, tp.Repo, tp.PRNumber, tp.PRURL, tp.PRTitle, tp.HeadBranch, tp.BaseBranch, tp.AuthorLogin, + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + tp.ID, tp.TaskID, tp.RepositoryID, tp.Owner, tp.Repo, tp.PRNumber, tp.PRURL, tp.PRTitle, tp.HeadBranch, tp.BaseBranch, tp.AuthorLogin, tp.State, tp.ReviewState, tp.ChecksState, tp.MergeableState, tp.ReviewCount, tp.PendingReviewCount, tp.CommentCount, tp.Additions, tp.Deletions, tp.CreatedAt, tp.MergedAt, tp.ClosedAt, tp.LastSyncedAt, tp.UpdatedAt) return err } -// GetTaskPR returns the PR association for a task. +// GetTaskPR returns the first PR association for a task. For multi-repo tasks +// the result is non-deterministic across repos — use ListTaskPRsByTask instead. func (s *Store) GetTaskPR(ctx context.Context, taskID string) (*TaskPR, error) { var tp TaskPR err := s.ro.GetContext(ctx, &tp, `SELECT * FROM github_task_prs WHERE task_id = ? LIMIT 1`, taskID) @@ -291,12 +543,45 @@ func (s *Store) GetTaskPR(ctx context.Context, taskID string) (*TaskPR, error) { return &tp, err } -// ListTaskPRsByTaskIDs returns PR associations for multiple tasks. -func (s *Store) ListTaskPRsByTaskIDs(ctx context.Context, taskIDs []string) (map[string]*TaskPR, error) { - if len(taskIDs) == 0 { - return make(map[string]*TaskPR), nil +// GetTaskPRByRepository returns the PR association for a (task, repository) +// pair, or nil if none. Use this for multi-repo tasks. +func (s *Store) GetTaskPRByRepository(ctx context.Context, taskID, repositoryID string) (*TaskPR, error) { + var tp TaskPR + err := s.ro.GetContext(ctx, &tp, + `SELECT * FROM github_task_prs WHERE task_id = ? AND repository_id = ? LIMIT 1`, + taskID, repositoryID) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil } - query, args, err := sqlx.In(`SELECT * FROM github_task_prs WHERE task_id IN (?)`, taskIDs) + return &tp, err +} + +// ListTaskPRsByTask returns every PR association for a task (one per repo +// when multi-repo). Empty slice when no PRs exist. +func (s *Store) ListTaskPRsByTask(ctx context.Context, taskID string) ([]*TaskPR, error) { + var prs []TaskPR + if err := s.ro.SelectContext(ctx, &prs, + `SELECT * FROM github_task_prs WHERE task_id = ? ORDER BY created_at ASC`, taskID); err != nil { + return nil, err + } + out := make([]*TaskPR, 0, len(prs)) + for i := range prs { + out = append(out, &prs[i]) + } + return out, nil +} + +// ListTaskPRsByTaskIDs returns PR associations for multiple tasks. Each task +// may have multiple PRs (one per repository for multi-repo tasks); rows are +// returned grouped by task_id, ordered by created_at ascending within a group. +func (s *Store) ListTaskPRsByTaskIDs(ctx context.Context, taskIDs []string) (map[string][]*TaskPR, error) { + if len(taskIDs) == 0 { + return make(map[string][]*TaskPR), nil + } + query, args, err := sqlx.In( + `SELECT * FROM github_task_prs WHERE task_id IN (?) ORDER BY created_at ASC`, + taskIDs, + ) if err != nil { return nil, err } @@ -305,34 +590,38 @@ func (s *Store) ListTaskPRsByTaskIDs(ctx context.Context, taskIDs []string) (map if err := s.ro.SelectContext(ctx, &prs, query, args...); err != nil { return nil, err } - result := make(map[string]*TaskPR, len(prs)) - for i := range prs { - result[prs[i].TaskID] = &prs[i] - } - return result, nil + return groupTaskPRsByTask(prs), nil } // ListTaskPRsByWorkspaceID returns all PR associations for tasks in a workspace. -func (s *Store) ListTaskPRsByWorkspaceID(ctx context.Context, workspaceID string) (map[string]*TaskPR, error) { +// Each task may have multiple PRs (one per repository for multi-repo tasks); +// rows are returned grouped by task_id, ordered by created_at ascending. +func (s *Store) ListTaskPRsByWorkspaceID(ctx context.Context, workspaceID string) (map[string][]*TaskPR, error) { var prs []TaskPR if err := s.ro.SelectContext(ctx, &prs, `SELECT gtp.* FROM github_task_prs gtp INNER JOIN tasks t ON gtp.task_id = t.id - WHERE t.workspace_id = ?`, workspaceID); err != nil { + WHERE t.workspace_id = ? + ORDER BY gtp.created_at ASC`, workspaceID); err != nil { return nil, err } - result := make(map[string]*TaskPR, len(prs)) + return groupTaskPRsByTask(prs), nil +} + +func groupTaskPRsByTask(prs []TaskPR) map[string][]*TaskPR { + result := make(map[string][]*TaskPR) for i := range prs { - result[prs[i].TaskID] = &prs[i] + taskID := prs[i].TaskID + result[taskID] = append(result[taskID], &prs[i]) } - return result, nil + return result } // ReplaceTaskPR atomically replaces the task→PR association for a task: any -// existing rows for task_id are deleted and the new row is inserted inside a -// single transaction, matching the effective 1:1 task→PR mapping used by -// reads. Use this when a task's active PR changes (e.g. the first PR was -// closed and a follow-up was opened). +// existing rows are deleted and the new row is inserted inside a single +// transaction. The delete is scoped to (task_id, repository_id) so multi-repo +// tasks only replace the row for the affected repo; for single-repo tasks +// (RepositoryID == "") the legacy semantics are preserved (delete all). func (s *Store) ReplaceTaskPR(ctx context.Context, tp *TaskPR) error { if tp.ID == "" { tp.ID = uuid.New().String() @@ -346,15 +635,31 @@ func (s *Store) ReplaceTaskPR(ctx context.Context, tp *TaskPR) error { } defer func() { _ = tx.Rollback() }() - if _, err := tx.ExecContext(ctx, `DELETE FROM github_task_prs WHERE task_id = ?`, tp.TaskID); err != nil { - return err + // Multi-repo: only delete the row for the same (task, repo) pair, leaving + // other repos' rows alone. Single-repo callers (RepositoryID == "") + // only delete legacy untagged rows so a multi-repo task's per-repo rows + // don't get wiped if a single-repo callsite slips in. The poller and + // service.AssociatePR* paths now always pass RepositoryID explicitly; + // any future caller that forgets does the safer thing (preserve tagged + // rows) instead of the old "nuke everything" behavior. + if tp.RepositoryID != "" { + if _, err := tx.ExecContext(ctx, + `DELETE FROM github_task_prs WHERE task_id = ? AND repository_id = ?`, + tp.TaskID, tp.RepositoryID); err != nil { + return err + } + } else { + if _, err := tx.ExecContext(ctx, + `DELETE FROM github_task_prs WHERE task_id = ? AND repository_id = ''`, tp.TaskID); err != nil { + return err + } } if _, err := tx.ExecContext(ctx, ` - INSERT INTO github_task_prs (id, task_id, owner, repo, pr_number, pr_url, pr_title, head_branch, base_branch, author_login, + INSERT INTO github_task_prs (id, task_id, repository_id, owner, repo, pr_number, pr_url, pr_title, head_branch, base_branch, author_login, state, review_state, checks_state, mergeable_state, review_count, pending_review_count, comment_count, additions, deletions, created_at, merged_at, closed_at, last_synced_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - tp.ID, tp.TaskID, tp.Owner, tp.Repo, tp.PRNumber, tp.PRURL, tp.PRTitle, tp.HeadBranch, tp.BaseBranch, tp.AuthorLogin, + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + tp.ID, tp.TaskID, tp.RepositoryID, tp.Owner, tp.Repo, tp.PRNumber, tp.PRURL, tp.PRTitle, tp.HeadBranch, tp.BaseBranch, tp.AuthorLogin, tp.State, tp.ReviewState, tp.ChecksState, tp.MergeableState, tp.ReviewCount, tp.PendingReviewCount, tp.CommentCount, tp.Additions, tp.Deletions, tp.CreatedAt, tp.MergedAt, tp.ClosedAt, tp.LastSyncedAt, tp.UpdatedAt); err != nil { return err diff --git a/apps/backend/internal/github/store_multi_repo_test.go b/apps/backend/internal/github/store_multi_repo_test.go new file mode 100644 index 0000000000..90fd998af6 --- /dev/null +++ b/apps/backend/internal/github/store_multi_repo_test.go @@ -0,0 +1,225 @@ +package github + +import ( + "context" + "path/filepath" + "testing" + "time" + + "github.com/jmoiron/sqlx" + + "github.com/kandev/kandev/internal/db" +) + +func newTestStore(t *testing.T) *Store { + t.Helper() + tmp := t.TempDir() + dbPath := filepath.Join(tmp, "github.db") + dbConn, err := db.OpenSQLite(dbPath) + if err != nil { + t.Fatalf("open db: %v", err) + } + sqlxDB := sqlx.NewDb(dbConn, "sqlite3") + if _, err := sqlxDB.Exec(`CREATE TABLE tasks (id TEXT PRIMARY KEY, archived_at DATETIME)`); err != nil { + t.Fatalf("create tasks table: %v", err) + } + store, err := NewStore(sqlxDB, sqlxDB) + if err != nil { + t.Fatalf("new store: %v", err) + } + return store +} + +func TestTaskPR_PerRepoStorage(t *testing.T) { + store := newTestStore(t) + ctx := context.Background() + + now := time.Now().UTC() + prFront := &TaskPR{ + TaskID: "task-1", RepositoryID: "repo-front", + Owner: "kdlbs", Repo: "kandev", PRNumber: 100, + PRURL: "https://github.com/kdlbs/kandev/pull/100", + PRTitle: "frontend changes", HeadBranch: "feat/x", BaseBranch: "main", + State: "open", CreatedAt: now, + } + prBack := &TaskPR{ + TaskID: "task-1", RepositoryID: "repo-back", + Owner: "kdlbs", Repo: "kandev-backend", PRNumber: 200, + PRURL: "https://github.com/kdlbs/kandev-backend/pull/200", + PRTitle: "backend changes", HeadBranch: "feat/x", BaseBranch: "main", + State: "open", CreatedAt: now, + } + + if err := store.CreateTaskPR(ctx, prFront); err != nil { + t.Fatalf("create front PR: %v", err) + } + if err := store.CreateTaskPR(ctx, prBack); err != nil { + t.Fatalf("create back PR: %v", err) + } + + gotFront, err := store.GetTaskPRByRepository(ctx, "task-1", "repo-front") + if err != nil { + t.Fatalf("get front: %v", err) + } + if gotFront == nil || gotFront.PRNumber != 100 { + t.Errorf("expected front PR #100, got %+v", gotFront) + } + + gotBack, err := store.GetTaskPRByRepository(ctx, "task-1", "repo-back") + if err != nil { + t.Fatalf("get back: %v", err) + } + if gotBack == nil || gotBack.PRNumber != 200 { + t.Errorf("expected back PR #200, got %+v", gotBack) + } + + all, err := store.ListTaskPRsByTask(ctx, "task-1") + if err != nil { + t.Fatalf("list: %v", err) + } + if len(all) != 2 { + t.Errorf("expected 2 PRs for task, got %d", len(all)) + } +} + +func TestTaskPR_ReplaceTaskPR_ScopedByRepository(t *testing.T) { + store := newTestStore(t) + ctx := context.Background() + + now := time.Now().UTC() + if err := store.CreateTaskPR(ctx, &TaskPR{ + TaskID: "task-2", RepositoryID: "repo-a", + Owner: "o", Repo: "r", PRNumber: 1, CreatedAt: now, + }); err != nil { + t.Fatalf("create A: %v", err) + } + if err := store.CreateTaskPR(ctx, &TaskPR{ + TaskID: "task-2", RepositoryID: "repo-b", + Owner: "o", Repo: "r2", PRNumber: 2, CreatedAt: now, + }); err != nil { + t.Fatalf("create B: %v", err) + } + + // Replace only repo-a's PR — repo-b must survive. + if err := store.ReplaceTaskPR(ctx, &TaskPR{ + TaskID: "task-2", RepositoryID: "repo-a", + Owner: "o", Repo: "r", PRNumber: 99, CreatedAt: now, + }); err != nil { + t.Fatalf("replace A: %v", err) + } + + all, _ := store.ListTaskPRsByTask(ctx, "task-2") + if len(all) != 2 { + t.Fatalf("expected 2 PRs after scoped replace, got %d", len(all)) + } + bySpec := map[string]int{} + for _, p := range all { + bySpec[p.RepositoryID] = p.PRNumber + } + if bySpec["repo-a"] != 99 { + t.Errorf("expected repo-a updated to 99, got %d", bySpec["repo-a"]) + } + if bySpec["repo-b"] != 2 { + t.Errorf("expected repo-b unchanged at 2, got %d", bySpec["repo-b"]) + } +} + +// Regression: a task that pre-dated the multi-repo schema kept a row with +// repository_id=”, then a later sync inserted a NEW row under the resolved +// repository_id — leaving two rows for the same PR and "+2" badges on the +// kanban card. backfillTaskPRsRepositoryID must dedup the legacy row. +func TestBackfillTaskPRsRepositoryID_DedupsLegacyEmptyRow(t *testing.T) { + store := newTestStore(t) + ctx := context.Background() + now := time.Now().UTC() + + // Insert a legacy row (repository_id = '') and a per-repo row for the + // same (task, owner, repo, pr_number). The unique index is + // (task_id, repository_id, pr_number), so both rows can coexist. + if err := store.CreateTaskPR(ctx, &TaskPR{ + ID: "legacy", TaskID: "task-x", RepositoryID: "", + Owner: "kdlbs", Repo: "kandev", PRNumber: 767, CreatedAt: now, + }); err != nil { + t.Fatalf("create legacy: %v", err) + } + if err := store.CreateTaskPR(ctx, &TaskPR{ + ID: "scoped", TaskID: "task-x", RepositoryID: "repo-1", + Owner: "kdlbs", Repo: "kandev", PRNumber: 767, CreatedAt: now, + }); err != nil { + t.Fatalf("create scoped: %v", err) + } + + if err := store.backfillTaskPRsRepositoryID(); err != nil { + t.Fatalf("backfill: %v", err) + } + + all, _ := store.ListTaskPRsByTask(ctx, "task-x") + if len(all) != 1 { + t.Fatalf("expected 1 PR after dedup, got %d", len(all)) + } + if all[0].RepositoryID != "repo-1" { + t.Errorf("expected scoped row to win, got repository_id=%q", all[0].RepositoryID) + } + + // Idempotent: second pass is a no-op. + if err := store.backfillTaskPRsRepositoryID(); err != nil { + t.Fatalf("second backfill: %v", err) + } + all2, _ := store.ListTaskPRsByTask(ctx, "task-x") + if len(all2) != 1 { + t.Errorf("idempotent expected 1 PR, got %d", len(all2)) + } +} + +// When task_repositories does not exist (e.g. github-store unit tests that +// init only this package's schema), backfill must skip the cross-package +// UPDATE rather than erroring out. +func TestBackfillTaskPRsRepositoryID_SkipsWhenTaskRepositoriesAbsent(t *testing.T) { + store := newTestStore(t) + if err := store.backfillTaskPRsRepositoryID(); err != nil { + t.Fatalf("backfill on store without task_repositories: %v", err) + } +} + +// Covers the UPDATE path: a legacy row with repository_id=” and NO per-repo +// counterpart should get its repository_id stamped from task_repositories, +// not deleted. This is the most common case in real upgrades — a task that +// has only ever had one PR (so no duplicate to dedup) but pre-dates the +// per-repo schema. +func TestBackfillTaskPRsRepositoryID_BackfillsOrphanRow(t *testing.T) { + store := newTestStore(t) + ctx := context.Background() + + if _, err := store.db.Exec(`CREATE TABLE task_repositories ( + id TEXT PRIMARY KEY, task_id TEXT NOT NULL, + repository_id TEXT NOT NULL, position INTEGER DEFAULT 0 + )`); err != nil { + t.Fatalf("create task_repositories: %v", err) + } + if _, err := store.db.Exec( + `INSERT INTO task_repositories VALUES ('r1','task-y','repo-abc',0)`, + ); err != nil { + t.Fatalf("seed task_repositories: %v", err) + } + + // Orphan legacy row: no per-repo counterpart, so the dedup DELETE leaves + // it alone and the UPDATE has to do the healing. + if err := store.CreateTaskPR(ctx, &TaskPR{ + ID: "legacy2", TaskID: "task-y", RepositoryID: "", + Owner: "o", Repo: "r", PRNumber: 1, CreatedAt: time.Now().UTC(), + }); err != nil { + t.Fatalf("create legacy: %v", err) + } + + if err := store.backfillTaskPRsRepositoryID(); err != nil { + t.Fatalf("backfill: %v", err) + } + + all, _ := store.ListTaskPRsByTask(ctx, "task-y") + if len(all) != 1 { + t.Fatalf("expected 1 row, got %d", len(all)) + } + if all[0].RepositoryID != "repo-abc" { + t.Errorf("expected backfilled repository_id='repo-abc', got %q", all[0].RepositoryID) + } +} diff --git a/apps/backend/internal/orchestrator/event_handlers_git.go b/apps/backend/internal/orchestrator/event_handlers_git.go index dc091d2d9d..d45c1d9087 100644 --- a/apps/backend/internal/orchestrator/event_handlers_git.go +++ b/apps/backend/internal/orchestrator/event_handlers_git.go @@ -5,6 +5,7 @@ import ( "crypto/sha256" "encoding/hex" "fmt" + "strings" "sync" "time" @@ -206,29 +207,91 @@ func (s *Service) persistGitStatusSnapshot(ctx context.Context, data watcher.Git } // trackPushAndAssociatePR detects git pushes by tracking the "ahead" count. -// When ahead transitions from >0 to 0 with a remote branch set, a push occurred. +// Two cases trigger detection: +// +// 1. Transition: ahead went from >0 to 0 with a remote branch set — the +// normal in-session push, observed across two status events. +// 2. First-observation sync: the very first status event for this +// (session, repo) already shows ahead=0 with a remote branch. This means +// a push happened before agentctl's poller saw the ahead>0 phase (the +// poll cadence missed it, or the session resumed after a restart). For a +// fresh task branch, RemoteBranch is only populated after `git push -u`, +// so seeing it pre-synced is itself a push signal. +// +// Without (2), multi-repo tasks routinely lose PR associations for any repo +// whose first poll happens to land after the push completes — the +// transition never gets observed and the watch never gets created. +// +// Multi-repo: keyed per (session, repository_name) so each repo's +// transitions are tracked independently. Without this, agentctl's per-repo +// status events race-overwrote each other's ahead counts and only one +// repo's push got detected. func (s *Service) trackPushAndAssociatePR(ctx context.Context, data watcher.GitEventData) { - prevAheadVal, loaded := s.pushTracker.Swap(data.SessionID, data.Status.Ahead) - if !loaded { - return // first status update for this session, skip - } - prevAhead, ok := prevAheadVal.(int) - if !ok || prevAhead <= 0 { + key := pushTrackerKey(data.SessionID, data.Status.RepositoryName) + prevAheadVal, loaded := s.pushTracker.Swap(key, data.Status.Ahead) + prevAhead, _ := prevAheadVal.(int) + if !shouldFirePushDetection(loaded, prevAhead, data.Status) { return } - // Push detected: ahead went from >0 to 0 - if data.Status.Ahead == 0 && data.Status.RemoteBranch != "" { - s.logger.Info("git push detected, starting PR association", - zap.String("session_id", data.SessionID), - zap.String("task_id", data.TaskID), - zap.String("branch", data.Status.Branch)) - go s.detectPushAndAssociatePR( - context.Background(), - data.SessionID, - data.TaskID, - data.Status.Branch, - ) + s.logger.Info("git push detected, starting PR association", + zap.String("session_id", data.SessionID), + zap.String("task_id", data.TaskID), + zap.String("repository_name", data.Status.RepositoryName), + zap.String("branch", data.Status.Branch), + zap.Bool("first_observation", !loaded)) + go s.detectPushAndAssociatePR( + context.Background(), + data.SessionID, + data.TaskID, + data.Status.RepositoryName, + data.Status.Branch, + ) +} + +// shouldFirePushDetection decides whether to kick off PR association for one +// status event. It fires in two cases (see trackPushAndAssociatePR doc): +// +// - Transition: the previous observation had ahead>0 and this one has ahead=0 +// with a remote branch set. +// - First observation: no previous entry, this one has ahead=0 with a +// remote branch set — meaning a push happened before agentctl's poller +// observed the ahead>0 phase. +// +// Pulled out as a pure function so the decision logic can be tested without +// spawning the goroutine that calls the GitHub API. +func shouldFirePushDetection(loaded bool, prevAhead int, status *lifecycle.GitStatusData) bool { + if status == nil { + return false } + if status.RemoteBranch == "" || status.Ahead != 0 { + return false + } + if !loaded { + return true + } + return prevAhead > 0 +} + +// pushTrackerKey builds the per-(session, repo) key used by pushTracker. +// Empty repository_name (single-repo / repo-less sessions) keeps the legacy +// single-key behaviour. +func pushTrackerKey(sessionID, repositoryName string) string { + return sessionID + "|" + repositoryName +} + +// pushTrackerForget drops every pushTracker entry belonging to a session. The +// tracker is keyed (session|repo); a single session can have N entries (one +// per repo in a multi-repo task). Called when the session is deleted so its +// tracker rows don't linger for the lifetime of the process. +func (s *Service) pushTrackerForget(sessionID string) { + prefix := sessionID + "|" + s.pushTracker.Range(func(k, _ any) bool { + key, ok := k.(string) + if ok && strings.HasPrefix(key, prefix) { + s.pushTracker.Delete(key) + } + return true + }) } // syncPRWatchBranch updates the PR watch branch if the live git branch @@ -377,15 +440,16 @@ func (s *Service) handleGitCommitCreated(ctx context.Context, data watcher.GitEv TaskID: data.TaskID, Timestamp: time.Now().Format("2006-01-02T15:04:05.000000000Z07:00"), Commit: &lifecycle.GitCommitData{ - CommitSHA: data.Commit.CommitSHA, - ParentSHA: data.Commit.ParentSHA, - Message: data.Commit.Message, - AuthorName: data.Commit.AuthorName, - AuthorEmail: data.Commit.AuthorEmail, - FilesChanged: data.Commit.FilesChanged, - Insertions: data.Commit.Insertions, - Deletions: data.Commit.Deletions, - CommittedAt: data.Commit.CommittedAt, + CommitSHA: data.Commit.CommitSHA, + ParentSHA: data.Commit.ParentSHA, + Message: data.Commit.Message, + AuthorName: data.Commit.AuthorName, + AuthorEmail: data.Commit.AuthorEmail, + FilesChanged: data.Commit.FilesChanged, + Insertions: data.Commit.Insertions, + Deletions: data.Commit.Deletions, + CommittedAt: data.Commit.CommittedAt, + RepositoryName: data.Commit.RepositoryName, }, }) _ = s.eventBus.Publish(ctx, events.BuildGitWSEventSubject(data.SessionID), event) @@ -415,8 +479,9 @@ func (s *Service) handleGitCommitsReset(ctx context.Context, data watcher.GitEve TaskID: data.TaskID, Timestamp: time.Now().Format("2006-01-02T15:04:05.000000000Z07:00"), Reset: &lifecycle.GitResetData{ - PreviousHead: data.Reset.PreviousHead, - CurrentHead: data.Reset.CurrentHead, + PreviousHead: data.Reset.PreviousHead, + CurrentHead: data.Reset.CurrentHead, + RepositoryName: data.Reset.RepositoryName, }, }) _ = s.eventBus.Publish(ctx, events.BuildGitWSEventSubject(data.SessionID), event) @@ -485,6 +550,7 @@ func (s *Service) handleBranchSwitched(ctx context.Context, data watcher.GitEven CurrentBranch: data.BranchSwitch.CurrentBranch, CurrentHead: data.BranchSwitch.CurrentHead, BaseCommit: data.BranchSwitch.BaseCommit, + RepositoryName: data.BranchSwitch.RepositoryName, }, }) _ = s.eventBus.Publish(ctx, events.BuildGitWSEventSubject(data.SessionID), event) diff --git a/apps/backend/internal/orchestrator/event_handlers_git_test.go b/apps/backend/internal/orchestrator/event_handlers_git_test.go index a4863a8830..9c773bba20 100644 --- a/apps/backend/internal/orchestrator/event_handlers_git_test.go +++ b/apps/backend/internal/orchestrator/event_handlers_git_test.go @@ -120,3 +120,109 @@ func TestHandleBranchSwitched_ResetsPRWatch(t *testing.T) { t.Errorf("reset watch branch = %q, want feature/b", ghSvc.resetWatchBranch) } } + +// shouldFirePushDetection is the trigger predicate for trackPushAndAssociatePR. +// The first-observation case is the regression that was breaking multi-repo +// PR detection in production: any repo whose first agentctl status poll landed +// after the push had completed would silently never get its PR associated, +// because the >0→0 transition was never observed. See task +// 4fdff41b-095a-4158-a311-4a1a23abe064 for the original failure mode. +func TestShouldFirePushDetection(t *testing.T) { + tests := []struct { + name string + loaded bool + prev int + status *lifecycle.GitStatusData + wantOn bool + wantWhy string + }{ + { + name: "first observation, ahead=0 with remote branch fires (push happened pre-poll)", + loaded: false, + prev: 0, + status: &lifecycle.GitStatusData{Ahead: 0, RemoteBranch: "origin/feature/x"}, + wantOn: true, + wantWhy: "first-observation sync — pre-existing remote branch in synced state means a push completed before we started watching", + }, + { + name: "first observation, no remote branch does not fire", + loaded: false, + prev: 0, + // Branch never been pushed — RemoteBranch is empty. + status: &lifecycle.GitStatusData{Ahead: 0, RemoteBranch: ""}, + wantOn: false, + }, + { + name: "first observation, ahead>0 does not fire (waiting for transition)", + loaded: false, + prev: 0, + status: &lifecycle.GitStatusData{Ahead: 3, RemoteBranch: "origin/feature/x"}, + wantOn: false, + }, + { + name: "transition >0 to 0 with remote fires (legacy in-session push)", + loaded: true, + prev: 2, + status: &lifecycle.GitStatusData{Ahead: 0, RemoteBranch: "origin/feature/x"}, + wantOn: true, + }, + { + name: "stays at 0 after first fire does not refire", + loaded: true, + prev: 0, + status: &lifecycle.GitStatusData{Ahead: 0, RemoteBranch: "origin/feature/x"}, + wantOn: false, + }, + { + name: "transition with no remote does not fire (local-only commit was undone)", + loaded: true, + prev: 1, + status: &lifecycle.GitStatusData{Ahead: 0, RemoteBranch: ""}, + wantOn: false, + }, + { + name: "nil status does not fire", + loaded: true, + prev: 1, + status: nil, + wantOn: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := shouldFirePushDetection(tt.loaded, tt.prev, tt.status) + if got != tt.wantOn { + t.Errorf("shouldFirePushDetection = %v, want %v", got, tt.wantOn) + } + }) + } +} + +// pushTrackerForget must drop every entry for the given session, regardless of +// how many repos are tracked under it. Multi-repo tasks accumulate one entry +// per repo (key = "session|repo"); leaving them behind on session delete +// would slowly leak memory across the lifetime of the process. +func TestPushTrackerForget(t *testing.T) { + svc := createTestService(setupTestRepo(t), newMockStepGetter(), newMockTaskRepo()) + + // Three sessions, multiple repos for s1. + svc.pushTracker.Store(pushTrackerKey("s1", "frontend"), 0) + svc.pushTracker.Store(pushTrackerKey("s1", "backend"), 0) + svc.pushTracker.Store(pushTrackerKey("s1", ""), 0) // legacy single-repo key + svc.pushTracker.Store(pushTrackerKey("s2", "frontend"), 0) + // "s10" is a guard against accidental prefix-match: "s1|" must not match "s10|". + svc.pushTracker.Store(pushTrackerKey("s10", "frontend"), 0) + + svc.pushTrackerForget("s1") + + for _, k := range []string{"s1|frontend", "s1|backend", "s1|"} { + if _, ok := svc.pushTracker.Load(k); ok { + t.Errorf("expected %q to be removed", k) + } + } + for _, k := range []string{"s2|frontend", "s10|frontend"} { + if _, ok := svc.pushTracker.Load(k); !ok { + t.Errorf("expected %q to survive (different session)", k) + } + } +} diff --git a/apps/backend/internal/orchestrator/event_handlers_github.go b/apps/backend/internal/orchestrator/event_handlers_github.go index cab6fb75b3..0e47764a16 100644 --- a/apps/backend/internal/orchestrator/event_handlers_github.go +++ b/apps/backend/internal/orchestrator/event_handlers_github.go @@ -24,15 +24,20 @@ const ( ) // GitHubService is the interface the orchestrator uses for GitHub operations. +// All PR-write methods carry repository_id so multi-repo tasks (where the +// same task spans multiple repos and so multiple PRs) don't collapse to one +// row by colliding on the legacy UNIQUE(session_id) / UNIQUE(task_id, pr_number) +// constraints. Empty repository_id preserves single-repo legacy behavior. type GitHubService interface { Client() github.Client - CreatePRWatch(ctx context.Context, sessionID, taskID, owner, repo string, prNumber int, branch string) (*github.PRWatch, error) - EnsurePRWatch(ctx context.Context, sessionID, taskID, owner, repo, branch string) (*github.PRWatch, error) + CreatePRWatch(ctx context.Context, sessionID, taskID, repositoryID, owner, repo string, prNumber int, branch string) (*github.PRWatch, error) + EnsurePRWatch(ctx context.Context, sessionID, taskID, repositoryID, owner, repo, branch string) (*github.PRWatch, error) GetPRWatchBySession(ctx context.Context, sessionID string) (*github.PRWatch, error) + GetPRWatchBySessionAndRepo(ctx context.Context, sessionID, repositoryID string) (*github.PRWatch, error) UpdatePRWatchBranchIfSearching(ctx context.Context, id, branch string) error UpdatePRWatchPRNumber(ctx context.Context, id string, prNumber int) error ResetPRWatch(ctx context.Context, id, branch string) error - AssociatePRWithTask(ctx context.Context, taskID string, pr *github.PR) (*github.TaskPR, error) + AssociatePRWithTask(ctx context.Context, taskID, repositoryID string, pr *github.PR) (*github.TaskPR, error) GetTaskPR(ctx context.Context, taskID string) (*github.TaskPR, error) ListActivePRWatches(ctx context.Context) ([]*github.PRWatch, error) ReserveReviewPRTask(ctx context.Context, watchID, repoOwner, repoName string, prNumber int, prURL string) (bool, error) @@ -254,7 +259,12 @@ func (s *Service) attachTaskToReservation(ctx context.Context, evt *github.NewRe zap.Int("pr_number", pr.Number), zap.Error(err)) } - if _, err := s.githubService.AssociatePRWithTask(ctx, taskID, pr); err != nil { + // Review tasks are single-repo (the PR's own repo). Resolve to that + // task_repository's repository_id so the resulting TaskPR row is scoped + // per-repo even though there's only one. Empty falls back to legacy + // "delete all" semantics which is fine for the 1-repo case. + repositoryID := s.resolvePrimaryTaskRepositoryID(ctx, taskID) + if _, err := s.githubService.AssociatePRWithTask(ctx, taskID, repositoryID, pr); err != nil { s.logger.Error("failed to associate PR with review task", zap.String("task_id", taskID), zap.Int("pr_number", pr.Number), @@ -362,13 +372,19 @@ func (s *Service) resolveReviewRepository(ctx context.Context, workspaceID strin // detectPushAndAssociatePR checks if a push happened and looks for a PR on // that branch. If no PR is found immediately, retries after a delay to handle // the case where the user creates the PR on GitHub shortly after pushing. -// The pushTracker entry for this session is always removed when the function returns -// to prevent unbounded growth of the tracker map. +// +// The pushTracker entry is intentionally NOT deleted on return: leaving the +// stored ahead=0 in place causes subsequent status events to fall into the +// `prevAhead <= 0` skip in trackPushAndAssociatePR, which prevents repeated +// firing on every status event for a synced branch. The tracker is cleaned +// up at session-deletion time via pushTrackerForget. +// +// Multi-repo: repositoryName scopes the lookup so each repo's push is detected +// and associated independently. Empty repositoryName falls back to the +// session's primary repo (legacy single-repo path). func (s *Service) detectPushAndAssociatePR( - ctx context.Context, sessionID, taskID, branch string, + ctx context.Context, sessionID, taskID, repositoryName, branch string, ) { - defer s.pushTracker.Delete(sessionID) - if s.githubService == nil { return } @@ -377,12 +393,17 @@ func (s *Service) detectPushAndAssociatePR( return } - // Check if we already have a watch for this session. + owner, repoName, repositoryID := s.resolvePushRepo(ctx, sessionID, taskID, repositoryName) + if owner == "" || repoName == "" { + return + } + + // Check if we already have a watch for this (session, repo). // If the watch already has a PR number, the PR was found — nothing to do. // If the watch has pr_number=0, it's still searching — do an immediate // search (faster than waiting for the 1-minute poller) and update the // watch branch if the agent pushed from a different branch. - existing, err := s.githubService.GetPRWatchBySession(ctx, sessionID) + existing, err := s.githubService.GetPRWatchBySessionAndRepo(ctx, sessionID, repositoryID) if err == nil && existing != nil { if existing.PRNumber > 0 { return // PR already found and being monitored @@ -391,11 +412,6 @@ func (s *Service) detectPushAndAssociatePR( return } - owner, repoName := s.resolveSessionRepo(ctx, sessionID) - if owner == "" || repoName == "" { - return - } - // Try to find a PR immediately, then retry after delays delays := []time.Duration{0, 30 * time.Second, 60 * time.Second} for _, delay := range delays { @@ -406,7 +422,7 @@ func (s *Service) detectPushAndAssociatePR( case <-time.After(delay): } // Re-check if a watch was created in the meantime (e.g. by CreatePR callback) - if ex, err := s.githubService.GetPRWatchBySession(ctx, sessionID); err == nil && ex != nil { + if ex, err := s.githubService.GetPRWatchBySessionAndRepo(ctx, sessionID, repositoryID); err == nil && ex != nil { return } } @@ -415,23 +431,66 @@ func (s *Service) detectPushAndAssociatePR( s.logger.Debug("no PR found for branch (will retry)", zap.String("branch", branch), zap.String("session_id", sessionID), + zap.String("repository_name", repositoryName), zap.Duration("delay", delay)) continue } s.logger.Info("PR found after push, associating with task", zap.String("session_id", sessionID), zap.String("task_id", taskID), + zap.String("repository_name", repositoryName), zap.Int("pr_number", foundPR.Number), zap.String("branch", branch)) - s.associatePRFromPush(ctx, sessionID, taskID, owner, repoName, branch, foundPR) + s.associatePRFromPushScoped(ctx, sessionID, taskID, owner, repoName, repositoryID, branch, foundPR) return } s.logger.Warn("exhausted all retries, no PR found after push", zap.String("session_id", sessionID), zap.String("task_id", taskID), + zap.String("repository_name", repositoryName), zap.String("branch", branch)) } +// resolvePushRepo returns (owner, name, repository_id) for the per-repo push +// detection. Multi-repo path: looks up the task_repository whose repository's +// Name matches `repositoryName`. Empty name falls back to the session's +// primary repo (legacy single-repo behaviour). +func (s *Service) resolvePushRepo( + ctx context.Context, sessionID, taskID, repositoryName string, +) (owner, repo, repositoryID string) { + if repositoryName == "" { + o, r := s.resolveSessionRepo(ctx, sessionID) + return o, r, s.resolvePrimaryTaskRepositoryID(ctx, taskID) + } + store, ok := s.repo.(repoStore) + if !ok { + return "", "", "" + } + links, err := store.ListTaskRepositories(ctx, taskID) + if err != nil { + return "", "", "" + } + for _, link := range links { + repoObj, err := store.GetRepository(ctx, link.RepositoryID) + if err != nil || repoObj == nil { + continue + } + if repoObj.Name != repositoryName { + continue + } + if repoObj.ProviderOwner == "" && repoObj.LocalPath != "" { + if p, o, n := service.ResolveGitRemoteProvider(repoObj.LocalPath); o != "" { + repoObj.Provider = p + repoObj.ProviderOwner = o + repoObj.ProviderName = n + go s.backfillRepoProvider(store, repoObj) + } + } + return repoObj.ProviderOwner, repoObj.ProviderName, link.RepositoryID + } + return "", "", "" +} + // searchPRForExistingWatch handles the case where a PR watch exists with pr_number=0 // (still searching). It updates the watch branch if the agent pushed from a different // branch, then does a single immediate search so we don't wait for the 1-minute poller. @@ -461,7 +520,11 @@ func (s *Service) searchPRForExistingWatch( zap.Int("pr_number", foundPR.Number), zap.Error(err)) } - if _, err := s.githubService.AssociatePRWithTask(ctx, taskID, foundPR); err != nil { + // Use the watch's own repository_id so the association lands on the + // correct per-repo TaskPR row (matters once multi-repo watches exist; + // for legacy single-repo watches this is empty and matches the old + // "delete all" behavior). + if _, err := s.githubService.AssociatePRWithTask(ctx, taskID, watch.RepositoryID, foundPR); err != nil { s.logger.Error("failed to associate PR with task", zap.String("task_id", taskID), zap.Int("pr_number", foundPR.Number), @@ -516,25 +579,160 @@ func (s *Service) backfillRepoProvider(store repoStore, repo *models.Repository) } } -// ensureSessionPRWatch creates a PRWatch (pr_number=0) for a session's branch -// so the poller will search for a PR on GitHub. Runs as a background goroutine. -func (s *Service) ensureSessionPRWatch(ctx context.Context, taskID, sessionID, branch string) { - branch = s.resolvePRWatchBranch(ctx, taskID, sessionID, branch) - if s.githubService == nil || branch == "" { +// ensureSessionPRWatch creates one PRWatch (pr_number=0) per repository in +// the task so the poller will search GitHub for each repo's PR. Runs as a +// background goroutine. +// +// Multi-repo: each repository gets its own watch row because the table is +// keyed UNIQUE(session_id, repository_id). Without this, only the primary +// repo's PR was ever discovered — secondary repos in a multi-repo task +// silently dropped their associations. +// +// `fallbackBranch` is used only when a task_repository has no checkout_branch +// AND the session worktree for that repo has no branch (rare; ensures +// backwards compatibility with single-repo callers that pass the resolved +// branch directly). +func (s *Service) ensureSessionPRWatch(ctx context.Context, taskID, sessionID, fallbackBranch string) { + if s.githubService == nil { return } - - owner, repoName := s.resolveTaskRepo(ctx, taskID) - if owner == "" || repoName == "" { - return + targets := s.resolveSessionWatchTargets(ctx, taskID, sessionID, fallbackBranch) + for _, t := range targets { + if _, err := s.githubService.EnsurePRWatch( + ctx, sessionID, taskID, t.RepositoryID, t.Owner, t.Repo, t.Branch, + ); err != nil { + s.logger.Warn("failed to ensure PR watch for session", + zap.String("session_id", sessionID), + zap.String("repository_id", t.RepositoryID), + zap.String("branch", t.Branch), + zap.Error(err)) + } } +} - if _, err := s.githubService.EnsurePRWatch(ctx, sessionID, taskID, owner, repoName, branch); err != nil { - s.logger.Warn("failed to ensure PR watch for session", - zap.String("session_id", sessionID), - zap.String("branch", branch), - zap.Error(err)) +// sessionWatchTarget describes one (session, repo) pair that should have a +// PR watch. Built by walking the task's repositories and matching each to +// the session's worktree (branch) and the repo (owner/name). +type sessionWatchTarget struct { + RepositoryID string + Owner string + Repo string + Branch string +} + +// resolveSessionWatchTargets walks the task's repositories and produces one +// target per repo whose owner/name and branch resolve. Skips repos for which +// any of those fields are empty — they can't be searched on GitHub. +// +// Branch resolution per repo (matches the legacy single-repo +// resolvePRWatchBranch priority so PR-review-style synthetic worktree +// branches don't override the task's real branch): +// +// 1. task_repository.checkout_branch (set at task creation — authoritative) +// 2. caller's fallbackBranch, but only for the primary repo (legacy signature) +// 3. session worktree's branch (last-resort guess from the worktree) +// +// Applying the fallback to non-primary repos would assign every repo the same +// branch and corrupt cross-repo watches. +func (s *Service) resolveSessionWatchTargets( + ctx context.Context, taskID, sessionID, fallbackBranch string, +) []sessionWatchTarget { + store, ok := s.repo.(repoStore) + if !ok { + return nil } + taskRepos, err := store.ListTaskRepositories(ctx, taskID) + if err != nil || len(taskRepos) == 0 { + return nil + } + branchByRepo := s.branchByRepoForSession(ctx, sessionID) + + var targets []sessionWatchTarget + for _, tr := range taskRepos { + owner, repoName := s.resolveRepoOwnerName(ctx, store, tr.RepositoryID) + if owner == "" || repoName == "" { + continue + } + branch := strings.TrimSpace(tr.CheckoutBranch) + if branch == "" && tr.Position == 0 { + branch = fallbackBranch + } + if branch == "" { + branch = branchByRepo[tr.RepositoryID] + } + if branch == "" { + continue + } + targets = append(targets, sessionWatchTarget{ + RepositoryID: tr.RepositoryID, + Owner: owner, + Repo: repoName, + Branch: branch, + }) + } + return targets +} + +// branchByRepoForSession returns repository_id → worktree_branch for every +// worktree on the session. Empty map if the session can't be loaded — caller +// falls back to the task_repository's configured branch. +func (s *Service) branchByRepoForSession(ctx context.Context, sessionID string) map[string]string { + session, err := s.repo.GetTaskSession(ctx, sessionID) + if err != nil || session == nil { + return nil + } + out := make(map[string]string, len(session.Worktrees)) + for _, wt := range session.Worktrees { + if wt.WorktreeBranch == "" || wt.RepositoryID == "" { + continue + } + out[wt.RepositoryID] = wt.WorktreeBranch + } + return out +} + +// resolveRepoOwnerName returns (owner, name) for a repository, backfilling +// provider info from the local git remote when missing. Mirrors what +// resolveTaskRepo does for the primary repo, but works against any repo id. +func (s *Service) resolveRepoOwnerName( + ctx context.Context, store repoStore, repositoryID string, +) (string, string) { + if repositoryID == "" { + return "", "" + } + repoObj, err := store.GetRepository(ctx, repositoryID) + if err != nil || repoObj == nil { + return "", "" + } + if repoObj.ProviderOwner == "" && repoObj.LocalPath != "" { + if p, o, n := service.ResolveGitRemoteProvider(repoObj.LocalPath); o != "" { + repoObj.Provider = p + repoObj.ProviderOwner = o + repoObj.ProviderName = n + go s.backfillRepoProvider(store, repoObj) + } + } + if repoObj.Provider != "github" { + return "", "" + } + return repoObj.ProviderOwner, repoObj.ProviderName +} + +// resolvePrimaryTaskRepositoryID returns the primary task_repository's +// repository_id for a task, or "" on miss / error. Used to scope PR +// watches and TaskPR rows to the correct per-repo row in multi-repo tasks +// (and to the single repo's row in single-repo tasks). Empty preserves the +// legacy single-repo behavior at the store layer. +func (s *Service) resolvePrimaryTaskRepositoryID(ctx context.Context, taskID string) string { + store, ok := s.repo.(repoStore) + if !ok { + return "" + } + taskRepo, err := store.GetPrimaryTaskRepository(ctx, taskID) + if err != nil || taskRepo == nil { + return "" + } + return taskRepo.RepositoryID } func (s *Service) resolvePRWatchBranch(ctx context.Context, taskID, sessionID, fallback string) string { @@ -594,24 +792,32 @@ func (s *Service) resolveTaskRepo(ctx context.Context, taskID string) (string, s return repoObj.ProviderOwner, repoObj.ProviderName } -// associatePRFromPush creates the PR watch and task-PR association after push detection. -func (s *Service) associatePRFromPush( - ctx context.Context, sessionID, taskID, owner, repoName, branch string, pr *github.PR, +// associatePRFromPushScoped creates the PR watch and task-PR association +// after push detection, scoped to a specific repository_id. Multi-repo callers +// pass the per-repo id resolved from the git event's repository_name; the +// legacy single-repo path passes the primary task_repository id. +func (s *Service) associatePRFromPushScoped( + ctx context.Context, sessionID, taskID, owner, repoName, repositoryID, branch string, pr *github.PR, ) { if _, watchErr := s.githubService.CreatePRWatch( - ctx, sessionID, taskID, owner, repoName, pr.Number, branch, + ctx, sessionID, taskID, repositoryID, owner, repoName, pr.Number, branch, ); watchErr != nil { s.logger.Error("failed to create PR watch on push detection", - zap.String("session_id", sessionID), zap.Error(watchErr)) + zap.String("session_id", sessionID), + zap.String("repository_id", repositoryID), + zap.Error(watchErr)) } - if _, assocErr := s.githubService.AssociatePRWithTask(ctx, taskID, pr); assocErr != nil { + if _, assocErr := s.githubService.AssociatePRWithTask(ctx, taskID, repositoryID, pr); assocErr != nil { s.logger.Error("failed to associate PR with task on push detection", - zap.String("task_id", taskID), zap.Error(assocErr)) + zap.String("task_id", taskID), + zap.String("repository_id", repositoryID), + zap.Error(assocErr)) } s.logger.Info("auto-detected PR from push", zap.String("session_id", sessionID), + zap.String("repository_id", repositoryID), zap.Int("pr_number", pr.Number), zap.String("branch", branch)) } @@ -642,7 +848,8 @@ func (s *Service) CheckSessionPR(ctx context.Context, taskID, sessionID string) } // Ensure a PR watch exists so the background poller will keep checking - if _, watchErr := s.githubService.EnsurePRWatch(ctx, sessionID, taskID, owner, repoName, branch); watchErr != nil { + repositoryID := s.resolvePrimaryTaskRepositoryID(ctx, taskID) + if _, watchErr := s.githubService.EnsurePRWatch(ctx, sessionID, taskID, repositoryID, owner, repoName, branch); watchErr != nil { s.logger.Warn("failed to ensure PR watch during check", zap.String("session_id", sessionID), zap.Error(watchErr)) @@ -659,7 +866,7 @@ func (s *Service) CheckSessionPR(ctx context.Context, taskID, sessionID string) } // Found a PR — associate it with the task - s.associatePRFromPush(ctx, sessionID, taskID, owner, repoName, branch, pr) + s.associatePRFromPushScoped(ctx, sessionID, taskID, owner, repoName, repositoryID, branch, pr) return true, nil } @@ -682,8 +889,10 @@ func (s *Service) ResolveBranchForSession(ctx context.Context, taskID, sessionID return s.resolvePRWatchBranch(ctx, taskID, sessionID, "") } -// buildTaskBranchList queries sessions with branches, filters out those with -// existing PR watches, and resolves GitHub owner/repo for the remainder. +// buildTaskBranchList walks sessions × their repositories and emits one +// TaskBranchInfo per (session, repository) that doesn't already have a PR +// watch. Multi-repo: previously dedup was keyed by sessionID, which silently +// dropped non-primary repos as soon as the primary one got a watch. func (s *Service) buildTaskBranchList(ctx context.Context, store repoStore) ([]github.TaskBranchInfo, error) { sessions, err := store.ListSessionsWithBranches(ctx) if err != nil { @@ -694,34 +903,35 @@ func (s *Service) buildTaskBranchList(ctx context.Context, store repoStore) ([]g } // Batch fetch existing watches to avoid N+1 queries. - watchedSessions := s.buildWatchedSessionSet(ctx) + watchedKeys := s.buildWatchedSessionRepoSet(ctx) var result []github.TaskBranchInfo for _, sess := range sessions { - if watchedSessions[sess.SessionID] { - continue + // Pass sess.Branch as fallback for the primary repo (legacy callers + // stored the worktree branch on SessionBranchInfo); per-repo branches + // come from session.Worktrees inside resolveSessionWatchTargets. + targets := s.resolveSessionWatchTargets(ctx, sess.TaskID, sess.SessionID, sess.Branch) + for _, t := range targets { + if watchedKeys[watchedSessionRepoKey(sess.SessionID, t.RepositoryID)] { + continue + } + result = append(result, github.TaskBranchInfo{ + TaskID: sess.TaskID, + SessionID: sess.SessionID, + RepositoryID: t.RepositoryID, + Owner: t.Owner, + Repo: t.Repo, + Branch: t.Branch, + }) } - owner, repo := s.resolveTaskRepo(ctx, sess.TaskID) - if owner == "" || repo == "" { - continue - } - branch := s.resolvePRWatchBranch(ctx, sess.TaskID, sess.SessionID, sess.Branch) - if branch == "" { - continue - } - result = append(result, github.TaskBranchInfo{ - TaskID: sess.TaskID, - SessionID: sess.SessionID, - Owner: owner, - Repo: repo, - Branch: branch, - }) } return result, nil } -// buildWatchedSessionSet returns a set of session IDs that already have PR watches. -func (s *Service) buildWatchedSessionSet(ctx context.Context) map[string]bool { +// buildWatchedSessionRepoSet returns the set of (session_id, repository_id) +// pairs that already have a PR watch row. Replaces the older session-only set +// which collapsed multi-repo sessions into one bucket. +func (s *Service) buildWatchedSessionRepoSet(ctx context.Context) map[string]bool { watches, err := s.githubService.ListActivePRWatches(ctx) if err != nil { s.logger.Debug("failed to list PR watches for reconciliation", zap.Error(err)) @@ -729,11 +939,16 @@ func (s *Service) buildWatchedSessionSet(ctx context.Context) map[string]bool { } set := make(map[string]bool, len(watches)) for _, w := range watches { - set[w.SessionID] = true + set[watchedSessionRepoKey(w.SessionID, w.RepositoryID)] = true } return set } +// watchedSessionRepoKey builds the per-(session, repo) dedup key. +func watchedSessionRepoKey(sessionID, repositoryID string) string { + return sessionID + "|" + repositoryID +} + // subscribeGitHubEvents subscribes to GitHub-related events on the event bus. func (s *Service) subscribeGitHubEvents() { if s.eventBus == nil { diff --git a/apps/backend/internal/orchestrator/event_handlers_github_test.go b/apps/backend/internal/orchestrator/event_handlers_github_test.go index 563c99a36e..fc67e15504 100644 --- a/apps/backend/internal/orchestrator/event_handlers_github_test.go +++ b/apps/backend/internal/orchestrator/event_handlers_github_test.go @@ -28,6 +28,12 @@ type mockGitHubService struct { createWatchBranch string updatedBranch string updatedPRNumber int + // repository_id captured by the most recent CreatePRWatch / + // AssociatePRWithTask call. Used by the multi-repo push tests to assert + // the per-repo scoping (an empty value indicates the legacy single-repo + // path landed on primary by accident). + lastCreateWatchRepositoryID string + lastAssociateRepositoryID string // Review PR reservation tracking. reserveCalls int @@ -49,7 +55,7 @@ func (m *mockGitHubService) Client() github.Client { return m.client } func (m *mockGitHubService) GetTaskPR(_ context.Context, _ string) (*github.TaskPR, error) { return m.taskPR, m.taskPRErr } -func (m *mockGitHubService) EnsurePRWatch(_ context.Context, _, _, _, _, branch string) (*github.PRWatch, error) { +func (m *mockGitHubService) EnsurePRWatch(_ context.Context, _, _, _, _, _, branch string) (*github.PRWatch, error) { m.ensureWatchCalls++ m.ensureWatchBranch = branch return &github.PRWatch{}, nil @@ -57,13 +63,18 @@ func (m *mockGitHubService) EnsurePRWatch(_ context.Context, _, _, _, _, branch func (m *mockGitHubService) GetPRWatchBySession(_ context.Context, _ string) (*github.PRWatch, error) { return m.prWatch, nil } -func (m *mockGitHubService) CreatePRWatch(_ context.Context, _, _, _, _ string, _ int, branch string) (*github.PRWatch, error) { +func (m *mockGitHubService) GetPRWatchBySessionAndRepo(_ context.Context, _, _ string) (*github.PRWatch, error) { + return m.prWatch, nil +} +func (m *mockGitHubService) CreatePRWatch(_ context.Context, _, _, repositoryID, _, _ string, _ int, branch string) (*github.PRWatch, error) { m.createWatchCalls++ m.createWatchBranch = branch + m.lastCreateWatchRepositoryID = repositoryID return &github.PRWatch{}, nil } -func (m *mockGitHubService) AssociatePRWithTask(_ context.Context, _ string, _ *github.PR) (*github.TaskPR, error) { +func (m *mockGitHubService) AssociatePRWithTask(_ context.Context, _, repositoryID string, _ *github.PR) (*github.TaskPR, error) { m.associateCalls++ + m.lastAssociateRepositoryID = repositoryID return &github.TaskPR{}, nil } func (m *mockGitHubService) UpdatePRWatchBranchIfSearching(_ context.Context, _, branch string) error { @@ -433,7 +444,7 @@ func TestDetectPushAndAssociatePR(t *testing.T) { ghSvc := &mockGitHubService{client: mockClient} svc.SetGitHubService(ghSvc) - svc.detectPushAndAssociatePR(ctx, "s1", "t1", "feature-branch") + svc.detectPushAndAssociatePR(ctx, "s1", "t1", "", "feature-branch") if ghSvc.associateCalls != 1 { t.Errorf("expected 1 AssociatePRWithTask call, got %d", ghSvc.associateCalls) @@ -454,7 +465,7 @@ func TestDetectPushAndAssociatePR(t *testing.T) { } svc.SetGitHubService(ghSvc) - svc.detectPushAndAssociatePR(ctx, "s1", "t1", "feature-branch") + svc.detectPushAndAssociatePR(ctx, "s1", "t1", "", "feature-branch") if ghSvc.associateCalls != 0 { t.Errorf("expected no AssociatePRWithTask calls when PR already found, got %d", ghSvc.associateCalls) @@ -475,7 +486,7 @@ func TestDetectPushAndAssociatePR(t *testing.T) { } svc.SetGitHubService(ghSvc) - svc.detectPushAndAssociatePR(ctx, "s1", "t1", "feature-branch") + svc.detectPushAndAssociatePR(ctx, "s1", "t1", "", "feature-branch") if ghSvc.associateCalls != 1 { t.Errorf("expected 1 AssociatePRWithTask call when pr_number=0, got %d", ghSvc.associateCalls) @@ -511,7 +522,7 @@ func TestDetectPushAndAssociatePR(t *testing.T) { svc.SetGitHubService(ghSvc) // Agent pushed from "new-branch" (different from watch's "old-branch") - svc.detectPushAndAssociatePR(ctx, "s1", "t1", "new-branch") + svc.detectPushAndAssociatePR(ctx, "s1", "t1", "", "new-branch") if ghSvc.updateBranchCalls != 1 { t.Errorf("expected 1 UpdatePRWatchBranch call, got %d", ghSvc.updateBranchCalls) @@ -537,12 +548,115 @@ func TestDetectPushAndAssociatePR(t *testing.T) { } svc.SetGitHubService(ghSvc) - svc.detectPushAndAssociatePR(ctx, "s1", "t1", "feature-branch") + svc.detectPushAndAssociatePR(ctx, "s1", "t1", "", "feature-branch") if ghSvc.updateBranchCalls != 0 { t.Errorf("expected no UpdatePRWatchBranch calls when branch matches, got %d", ghSvc.updateBranchCalls) } }) + + // Multi-repo: when the push event carries a repository_name, the resolver + // must look up that specific task_repository (not the primary one) and + // associate the PR under its repository_id. Without this, a multi-repo + // task only ever gets one PR detected — the second repo's push silently + // drops or lands under the wrong repository_id. + t.Run("resolvePushRepo multi-repo: scopes to the named repository", func(t *testing.T) { + repo := setupTestRepo(t) + seedSession(t, repo, "t1", "s1", "step1") + + // Two repos linked to the task; agent pushes to the secondary one. + primary := &models.Repository{ + ID: "repo-front", WorkspaceID: "ws1", Name: "frontend", + SourceType: "provider", Provider: "github", + ProviderOwner: "myorg", ProviderName: "frontend", + CreatedAt: now, UpdatedAt: now, + } + secondary := &models.Repository{ + ID: "repo-back", WorkspaceID: "ws1", Name: "backend", + SourceType: "provider", Provider: "github", + ProviderOwner: "myorg", ProviderName: "backend", + CreatedAt: now, UpdatedAt: now, + } + if err := repo.CreateRepository(ctx, primary); err != nil { + t.Fatalf("create primary: %v", err) + } + if err := repo.CreateRepository(ctx, secondary); err != nil { + t.Fatalf("create secondary: %v", err) + } + if err := repo.CreateTaskRepository(ctx, &models.TaskRepository{ + ID: "tr-1", TaskID: "t1", RepositoryID: "repo-front", Position: 0, CreatedAt: now, UpdatedAt: now, + }); err != nil { + t.Fatalf("link primary: %v", err) + } + if err := repo.CreateTaskRepository(ctx, &models.TaskRepository{ + ID: "tr-2", TaskID: "t1", RepositoryID: "repo-back", Position: 1, CreatedAt: now, UpdatedAt: now, + }); err != nil { + t.Fatalf("link secondary: %v", err) + } + + svc := createTestService(repo, newMockStepGetter(), newMockTaskRepo()) + mockClient := github.NewMockClient() + mockClient.AddPR(&github.PR{ + Number: 99, Title: "Backend PR", + HTMLURL: "https://github.com/myorg/backend/pull/99", + AuthorLogin: "alice", + RepoOwner: "myorg", RepoName: "backend", + HeadBranch: "feature-x", BaseBranch: "main", + }) + ghSvc := &mockGitHubService{client: mockClient} + svc.SetGitHubService(ghSvc) + + // Push event came from the "backend" repo subdir — must associate + // under repo-back, NOT repo-front. + svc.detectPushAndAssociatePR(ctx, "s1", "t1", "backend", "feature-x") + + if ghSvc.associateCalls != 1 { + t.Fatalf("expected 1 AssociatePRWithTask call, got %d", ghSvc.associateCalls) + } + if ghSvc.lastAssociateRepositoryID != "repo-back" { + t.Errorf("expected association under repo-back, got %q", ghSvc.lastAssociateRepositoryID) + } + if ghSvc.createWatchCalls != 1 { + t.Errorf("expected 1 CreatePRWatch call, got %d", ghSvc.createWatchCalls) + } + if ghSvc.lastCreateWatchRepositoryID != "repo-back" { + t.Errorf("expected watch under repo-back, got %q", ghSvc.lastCreateWatchRepositoryID) + } + }) + + // Symmetric guard: an unknown repository_name (not linked to the task) + // must not fall back to the primary — silently mis-associating a PR is + // worse than dropping the event. + t.Run("resolvePushRepo multi-repo: drops events for unknown repository_name", func(t *testing.T) { + repo := setupTestRepo(t) + seedSession(t, repo, "t1", "s1", "step1") + if err := repo.CreateRepository(ctx, &models.Repository{ + ID: "repo-front", WorkspaceID: "ws1", Name: "frontend", + SourceType: "provider", Provider: "github", + ProviderOwner: "myorg", ProviderName: "frontend", + CreatedAt: now, UpdatedAt: now, + }); err != nil { + t.Fatalf("create: %v", err) + } + if err := repo.CreateTaskRepository(ctx, &models.TaskRepository{ + ID: "tr-1", TaskID: "t1", RepositoryID: "repo-front", Position: 0, CreatedAt: now, UpdatedAt: now, + }); err != nil { + t.Fatalf("link: %v", err) + } + + svc := createTestService(repo, newMockStepGetter(), newMockTaskRepo()) + ghSvc := &mockGitHubService{client: github.NewMockClient()} + svc.SetGitHubService(ghSvc) + + svc.detectPushAndAssociatePR(ctx, "s1", "t1", "ghost-repo", "feature-x") + + if ghSvc.associateCalls != 0 { + t.Errorf("expected 0 AssociatePRWithTask calls for unknown repo, got %d", ghSvc.associateCalls) + } + if ghSvc.createWatchCalls != 0 { + t.Errorf("expected 0 CreatePRWatch calls for unknown repo, got %d", ghSvc.createWatchCalls) + } + }) } func TestListTasksNeedingPRWatch(t *testing.T) { @@ -673,4 +787,140 @@ func TestListTasksNeedingPRWatch(t *testing.T) { t.Errorf("expected empty result when github service is nil, got %d", len(tasks)) } }) + + // Regression for task a7a59bd8: a multi-repo task only ever had a watch + // for its primary repo, so the poller never searched GitHub for the + // non-primary repo's PR. The fix iterates per (session, repo). + t.Run("multi-repo: emits one entry per repository", func(t *testing.T) { + testRepo := setupTestRepo(t) + seedSession(t, testRepo, "t1", "s1", "step1") + + // Two repos linked to one task, one worktree per repo with its own branch. + repos := []*models.Repository{ + { + ID: "repo-front", WorkspaceID: "ws1", Name: "frontend", + SourceType: "provider", Provider: "github", + ProviderOwner: "myorg", ProviderName: "frontend", + CreatedAt: now, UpdatedAt: now, + }, + { + ID: "repo-back", WorkspaceID: "ws1", Name: "backend", + SourceType: "provider", Provider: "github", + ProviderOwner: "myorg", ProviderName: "backend", + CreatedAt: now, UpdatedAt: now, + }, + } + for _, r := range repos { + if err := testRepo.CreateRepository(ctx, r); err != nil { + t.Fatalf("create repo %s: %v", r.ID, err) + } + } + links := []*models.TaskRepository{ + {ID: "tr-1", TaskID: "t1", RepositoryID: "repo-front", Position: 0, CreatedAt: now, UpdatedAt: now}, + {ID: "tr-2", TaskID: "t1", RepositoryID: "repo-back", Position: 1, CreatedAt: now, UpdatedAt: now}, + } + for _, l := range links { + if err := testRepo.CreateTaskRepository(ctx, l); err != nil { + t.Fatalf("link %s: %v", l.ID, err) + } + } + worktrees := []*models.TaskSessionWorktree{ + {ID: "wt-1", SessionID: "s1", WorktreeID: "wtree-1", RepositoryID: "repo-front", WorktreeBranch: "feat/frontend", CreatedAt: now}, + {ID: "wt-2", SessionID: "s1", WorktreeID: "wtree-2", RepositoryID: "repo-back", WorktreeBranch: "feat/backend", CreatedAt: now}, + } + for _, wt := range worktrees { + if err := testRepo.CreateTaskSessionWorktree(ctx, wt); err != nil { + t.Fatalf("worktree %s: %v", wt.ID, err) + } + } + + svc := createTestService(testRepo, newMockStepGetter(), newMockTaskRepo()) + // Mock the watch list as if frontend already has a watch (the + // primary repo). Pre-fix this would have suppressed both repos — + // fix: secondary repo still emits. + svc.SetGitHubService(&mockGitHubService{}) + + tasks, err := svc.ListTasksNeedingPRWatch(ctx) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + // Expect both repos to be emitted (no existing watches in the mock). + byRepo := map[string]github.TaskBranchInfo{} + for _, ti := range tasks { + byRepo[ti.RepositoryID] = ti + } + front, ok := byRepo["repo-front"] + if !ok { + t.Fatal("expected primary repo entry, missing") + } + if front.Owner != "myorg" || front.Repo != "frontend" || front.Branch != "feat/frontend" { + t.Errorf("primary entry mis-resolved: %+v", front) + } + back, ok := byRepo["repo-back"] + if !ok { + t.Fatal("expected secondary repo entry, missing — this was the regression") + } + if back.Owner != "myorg" || back.Repo != "backend" || back.Branch != "feat/backend" { + t.Errorf("secondary entry mis-resolved: %+v", back) + } + }) +} + +// TestEnsureSessionPRWatch_MultiRepo guards the same multi-repo gap on the +// session-start path: ensureSessionPRWatch (called when a session is launched +// or resumed) must create a watch row for every repo on the task, not only +// the primary one. Pre-fix only one EnsurePRWatch call landed regardless of +// repo count, so secondary repos waited indefinitely for the never-firing +// poller and never associated their PRs. +func TestEnsureSessionPRWatch_MultiRepo(t *testing.T) { + ctx := context.Background() + now := time.Now().UTC() + + repo := setupTestRepo(t) + seedSession(t, repo, "t1", "s1", "step1") + + for _, r := range []*models.Repository{ + { + ID: "repo-front", WorkspaceID: "ws1", Name: "frontend", + SourceType: "provider", Provider: "github", + ProviderOwner: "myorg", ProviderName: "frontend", + CreatedAt: now, UpdatedAt: now, + }, + { + ID: "repo-back", WorkspaceID: "ws1", Name: "backend", + SourceType: "provider", Provider: "github", + ProviderOwner: "myorg", ProviderName: "backend", + CreatedAt: now, UpdatedAt: now, + }, + } { + if err := repo.CreateRepository(ctx, r); err != nil { + t.Fatalf("create repo: %v", err) + } + } + for _, l := range []*models.TaskRepository{ + {ID: "tr-1", TaskID: "t1", RepositoryID: "repo-front", Position: 0, CreatedAt: now, UpdatedAt: now}, + {ID: "tr-2", TaskID: "t1", RepositoryID: "repo-back", Position: 1, CreatedAt: now, UpdatedAt: now}, + } { + if err := repo.CreateTaskRepository(ctx, l); err != nil { + t.Fatalf("link: %v", err) + } + } + for _, wt := range []*models.TaskSessionWorktree{ + {ID: "wt-1", SessionID: "s1", WorktreeID: "wtree-1", RepositoryID: "repo-front", WorktreeBranch: "feat/frontend", CreatedAt: now}, + {ID: "wt-2", SessionID: "s1", WorktreeID: "wtree-2", RepositoryID: "repo-back", WorktreeBranch: "feat/backend", CreatedAt: now}, + } { + if err := repo.CreateTaskSessionWorktree(ctx, wt); err != nil { + t.Fatalf("worktree: %v", err) + } + } + + svc := createTestService(repo, newMockStepGetter(), newMockTaskRepo()) + ghSvc := &mockGitHubService{} + svc.SetGitHubService(ghSvc) + + svc.ensureSessionPRWatch(ctx, "t1", "s1", "ignored-fallback") + + if ghSvc.ensureWatchCalls != 2 { + t.Errorf("expected 2 EnsurePRWatch calls (one per repo), got %d", ghSvc.ensureWatchCalls) + } } diff --git a/apps/backend/internal/orchestrator/executor/executor.go b/apps/backend/internal/orchestrator/executor/executor.go index f97b766de6..e2831d7bb1 100644 --- a/apps/backend/internal/orchestrator/executor/executor.go +++ b/apps/backend/internal/orchestrator/executor/executor.go @@ -24,6 +24,7 @@ type executorStore interface { UpdateTaskState(ctx context.Context, id string, state v1.TaskState) error // Task↔repo junction GetPrimaryTaskRepository(ctx context.Context, taskID string) (*models.TaskRepository, error) + ListTaskRepositories(ctx context.Context, taskID string) ([]*models.TaskRepository, error) // Session CreateTaskSession(ctx context.Context, session *models.TaskSession) error GetTaskSession(ctx context.Context, id string) (*models.TaskSession, error) @@ -48,6 +49,8 @@ type executorStore interface { GetTaskEnvironmentByTaskID(ctx context.Context, taskID string) (*models.TaskEnvironment, error) CreateTaskEnvironment(ctx context.Context, env *models.TaskEnvironment) error UpdateTaskEnvironment(ctx context.Context, env *models.TaskEnvironment) error + CreateTaskEnvironmentRepo(ctx context.Context, repo *models.TaskEnvironmentRepo) error + ListTaskEnvironmentRepos(ctx context.Context, envID string) ([]*models.TaskEnvironmentRepo, error) // Session history + plan (for context handover) ListTaskSessions(ctx context.Context, taskID string) ([]*models.TaskSession, error) GetTaskPlan(ctx context.Context, taskID string) (*models.TaskPlan, error) @@ -257,6 +260,30 @@ type LaunchAgentRequest struct { // Task directory mode: place worktree at ~/.kandev/tasks/{TaskDirName}/{RepoName}/ TaskDirName string // Semantic task directory name (e.g. "fix-bug_ab12") RepoName string // Repository name used as subdirectory inside the task directory + + // Repositories carries one entry per repository when the launch is multi-repo. + // When non-empty it is the source of truth and the legacy single-repo + // top-level fields above are populated from Repositories[0] for backwards + // compatibility with code paths that have not yet been updated. + Repositories []RepoSpec +} + +// RepoSpec describes one repository for a multi-repo task launch from the +// orchestrator. Mirrors lifecycle.RepoLaunchSpec; kept as a separate type so +// the orchestrator package does not need to import lifecycle types into its +// public API. +type RepoSpec struct { + RepositoryID string + RepositoryPath string + RepositoryURL string + RepoName string + BaseBranch string + CheckoutBranch string + WorktreeID string + WorktreeBranchPrefix string + PullBeforeWorktree bool + RepoSetupScript string + RepoCleanupScript string } // McpModeConfig activates config-mode MCP tools (workflow steps, agents, MCP @@ -284,6 +311,22 @@ type LaunchAgentResponse struct { WorktreeBranch string Metadata map[string]interface{} PrepareResult *lifecycle.EnvPrepareResult `json:"-"` // Carried from lifecycle.Launch for synchronous persistence + + // Worktrees is the per-repository preparer result list when the launch is + // multi-repo. Empty for single-repo launches; the legacy WorktreeID/Path/ + // Branch fields above mirror Worktrees[0] in that case. + Worktrees []RepoWorktreeResult +} + +// RepoWorktreeResult mirrors lifecycle.RepoWorktreeResult for the orchestrator +// API surface. One entry per repository prepared during a multi-repo launch. +type RepoWorktreeResult struct { + RepositoryID string + WorktreeID string + WorktreeBranch string + WorktreePath string + MainRepoGitDir string + ErrorMessage string } // TaskExecution tracks an active task execution diff --git a/apps/backend/internal/orchestrator/executor/executor_execute.go b/apps/backend/internal/orchestrator/executor/executor_execute.go index da692afbb4..f0be99510a 100644 --- a/apps/backend/internal/orchestrator/executor/executor_execute.go +++ b/apps/backend/internal/orchestrator/executor/executor_execute.go @@ -339,7 +339,22 @@ func (e *Executor) LaunchPreparedSession(ctx context.Context, task *v1.Task, ses executorID := opts.ExecutorID prompt := opts.Prompt startAgent := opts.StartAgent - // Fetch the session to get its configuration + // Serialise concurrent launches for the same session. Two callers reach + // this path on every task: PrepareTaskSession spawns a background launch + // (workspace only) the moment a session is created, and StartTaskWithSession + // is called when the agent is actually started (auto-start, user click). + // Without this lock both run env-prep + executionStore.Add in parallel and + // the second one fails at register with "already has an agent running + // (race resolved during register)" — visible in the UI as + // "Environment setup failed". Multi-repo amplifies this because the + // per-repo prep runs sequentially, widening the race window. + sessionLock := e.getSessionLock(sessionID) + sessionLock.Lock() + defer sessionLock.Unlock() + + // Re-fetch the session under the lock so the fast-path check below sees + // any AgentExecutionID the previous holder just persisted. Without the + // re-fetch we'd hold a stale snapshot and run a second full launch. session, err := e.repo.GetTaskSession(ctx, sessionID) if err != nil { e.logger.Error("failed to get session for launch", @@ -370,10 +385,19 @@ func (e *Executor) LaunchPreparedSession(ctx context.Context, task *v1.Task, ses zap.String("session_id", sessionID)) } - repoInfo, err := e.resolvePrimaryRepoInfo(ctx, task.ID) + allRepos, err := e.resolveAllRepoInfo(ctx, task.ID) if err != nil { return nil, err } + // Primary = first by Position. For repo-less tasks (e.g. quick chat), allRepos + // is empty and primary is a zero-value placeholder; downstream code already + // handles the missing-repo path. + var primaryRepo *repoInfo + if len(allRepos) > 0 { + primaryRepo = allRepos[0] + } else { + primaryRepo = &repoInfo{} + } // Resolve the env ID before LaunchAgent so the in-memory AgentExecution // is env-scoped from the first shell/layout request, not only after DB @@ -381,7 +405,7 @@ func (e *Executor) LaunchPreparedSession(ctx context.Context, task *v1.Task, ses existingEnv, _ := e.repo.GetTaskEnvironmentByTaskID(ctx, task.ID) assignLaunchTaskEnvironmentID(session, existingEnv) - req, execCfg, err := e.buildLaunchAgentRequest(ctx, task, session, agentProfileID, executorID, prompt, repoInfo) + req, execCfg, err := e.buildLaunchAgentRequest(ctx, task, session, agentProfileID, executorID, prompt, primaryRepo, allRepos) if err != nil { return nil, err } @@ -432,7 +456,7 @@ func (e *Executor) LaunchPreparedSession(ctx context.Context, task *v1.Task, ses e.captureBaseCommit(captureCtx, sid) }(sessionID) - return e.finalizeLaunch(ctx, task, session, agentProfileID, sessionID, repoInfo, resp, startAgent, execCfg) + return e.finalizeLaunch(ctx, task, session, agentProfileID, sessionID, primaryRepo, resp, startAgent, execCfg) } // handleLaunchFailure marks the session and task as FAILED and returns the original error. @@ -509,7 +533,9 @@ func assignLaunchTaskEnvironmentID(session *models.TaskSession, existingEnv *mod // buildLaunchAgentRequest constructs a LaunchAgentRequest for a new session launch, // applying executor config, repository/worktree settings, and remote docker URL as needed. -func (e *Executor) buildLaunchAgentRequest(ctx context.Context, task *v1.Task, session *models.TaskSession, agentProfileID, executorID, prompt string, repoInfo *repoInfo) (*LaunchAgentRequest, executorConfig, error) { +// allRepos carries every repository for the task in Position order; for single-repo +// or repo-less tasks it has length <=1 and the legacy single-repo path runs unchanged. +func (e *Executor) buildLaunchAgentRequest(ctx context.Context, task *v1.Task, session *models.TaskSession, agentProfileID, executorID, prompt string, repoInfo *repoInfo, allRepos []*repoInfo) (*LaunchAgentRequest, executorConfig, error) { metadata := cloneMetadata(task.Metadata) if session.ExecutorProfileID != "" { if metadata == nil { @@ -560,6 +586,14 @@ func (e *Executor) buildLaunchAgentRequest(ctx context.Context, task *v1.Task, s return nil, execConfig, err } + // Multi-repo: when more than one repository is associated with the task, + // populate req.Repositories so the lifecycle preparer creates one worktree + // per repo. The legacy single-repo top-level fields above stay populated + // (mirroring the primary) for downstream code that has not been migrated. + if len(allRepos) > 1 { + req.Repositories = buildRepoSpecs(allRepos) + } + // Activate config-mode MCP tools when config_mode is set in session metadata. if isConfigModeSession(session) { req.McpMode = McpModeConfig @@ -579,6 +613,36 @@ func (e *Executor) applyContainerCredentials(ctx context.Context, req *LaunchAge e.injectGitHubTokenFromCLI(ctx, req) // Final fallback to local gh CLI } +// buildRepoSpecs converts resolved repoInfos into per-repo launch specs for +// the lifecycle layer. Used only when the task has more than one repository. +func buildRepoSpecs(allRepos []*repoInfo) []RepoSpec { + out := make([]RepoSpec, 0, len(allRepos)) + for _, info := range allRepos { + spec := RepoSpec{ + RepositoryID: info.RepositoryID, + RepositoryPath: info.RepositoryPath, + BaseBranch: info.BaseBranch, + CheckoutBranch: info.CheckoutBranch, + WorktreeBranchPrefix: info.WorktreeBranchPrefix, + PullBeforeWorktree: info.PullBeforeWorktree, + } + if info.Repository != nil { + spec.RepoName = info.Repository.Name + spec.RepoSetupScript = info.Repository.SetupScript + spec.RepoCleanupScript = info.Repository.CleanupScript + } + // Containerized executors need a clone URL; reuse the same helper as + // the single-repo path (best-effort — skipped if Repository is nil). + if info.Repository != nil { + if u := repositoryCloneURL(info.Repository); u != "" { + spec.RepositoryURL = u + } + } + out = append(out, spec) + } + return out +} + // applyRepositoryConfig sets repository-related fields on the request and resolves clone URLs. func (e *Executor) applyRepositoryConfig(req *LaunchAgentRequest, task *v1.Task, repoInfo *repoInfo, execConfig executorConfig, metadata map[string]interface{}) (map[string]interface{}, error) { if repoInfo.RepositoryPath != "" { @@ -821,20 +885,31 @@ func (e *Executor) injectHandoverIfNeeded(ctx context.Context, taskID, currentSe // persistTaskEnvironment so the rule lives in exactly one place — without // this, the update branch silently leaves workspace_path empty, which // produces ErrSessionWorkspaceNotReady forever in the env terminal handler. +// +// In task-directory mode the desired value is always the task root that holds +// every per-repo worktree as a sibling. The legacy single-repo path passes +// resp.WorktreePath as that repo's subdir, so the parent is the task root. +// The multi-repo path on the lifecycle adapter mirrors agentctl's WorkDir +// (already the task root) into resp.WorktreePath, so it must be used as-is — +// applying filepath.Dir would walk up one level too far and point at the +// /tasks holder instead. func computeWorkspacePath(req *LaunchAgentRequest, resp *LaunchAgentResponse) string { workspacePath := resp.WorktreePath if workspacePath == "" { workspacePath = req.RepositoryPath } - // Task directory mode: WorkspacePath = task root, WorktreePath = repo subdir. - if req.TaskDirName != "" && resp.WorktreePath != "" { - workspacePath = filepath.Dir(resp.WorktreePath) + if req.TaskDirName == "" || resp.WorktreePath == "" { + return workspacePath } - return workspacePath + if len(resp.Worktrees) > 1 { + return resp.WorktreePath + } + return filepath.Dir(resp.WorktreePath) } // persistTaskEnvironment creates or updates the task environment record after a successful launch. -// It also links the session to the environment via TaskEnvironmentID. +// It also links the session to the environment via TaskEnvironmentID. For +// multi-repo launches it additionally writes one TaskEnvironmentRepo row per repo. // // Serialised per-task: concurrent launches for the same task previously raced // here (each saw existingEnv == nil, each created a new row, both succeeded @@ -894,6 +969,8 @@ func (e *Executor) persistTaskEnvironment( zap.Error(err)) } session.TaskEnvironmentID = existingEnv.ID + // Persist per-repo rows for multi-repo launches that didn't have them yet. + e.persistTaskEnvironmentRepos(ctx, existingEnv.ID, resp.Worktrees) return } @@ -911,6 +988,11 @@ func (e *Executor) persistTaskEnvironment( WorktreeBranch: resp.WorktreeBranch, WorkspacePath: workspacePath, ContainerID: resp.ContainerID, + TaskDirName: req.TaskDirName, + } + // Embed per-repo rows in the same create transaction when multi-repo. + if len(resp.Worktrees) > 0 { + env.Repos = buildTaskEnvironmentRepos(resp.Worktrees) } if err := e.repo.CreateTaskEnvironment(ctx, env); err != nil { e.logger.Warn("failed to create task environment", @@ -920,3 +1002,60 @@ func (e *Executor) persistTaskEnvironment( } session.TaskEnvironmentID = env.ID } + +// buildTaskEnvironmentRepos converts per-repo worktree results into env-repo rows. +// TaskEnvironmentID is left blank — it is set by the env Create transaction. +func buildTaskEnvironmentRepos(worktrees []RepoWorktreeResult) []*models.TaskEnvironmentRepo { + out := make([]*models.TaskEnvironmentRepo, 0, len(worktrees)) + for i, w := range worktrees { + out = append(out, &models.TaskEnvironmentRepo{ + RepositoryID: w.RepositoryID, + WorktreeID: w.WorktreeID, + WorktreePath: w.WorktreePath, + WorktreeBranch: w.WorktreeBranch, + Position: i, + ErrorMessage: w.ErrorMessage, + }) + } + return out +} + +// persistTaskEnvironmentRepos inserts per-repo rows under an existing env id, +// skipping any (env, repo) pair that already exists. Used when an existing +// environment is reused (resume / re-launch on the same task). +func (e *Executor) persistTaskEnvironmentRepos(ctx context.Context, envID string, worktrees []RepoWorktreeResult) { + if envID == "" || len(worktrees) == 0 { + return + } + existing, err := e.repo.ListTaskEnvironmentRepos(ctx, envID) + if err != nil { + e.logger.Warn("failed to list existing task_environment_repos before insert", + zap.String("env_id", envID), + zap.Error(err)) + return + } + have := make(map[string]bool, len(existing)) + for _, row := range existing { + have[row.RepositoryID] = true + } + for i, w := range worktrees { + if w.RepositoryID == "" || have[w.RepositoryID] { + continue + } + row := &models.TaskEnvironmentRepo{ + TaskEnvironmentID: envID, + RepositoryID: w.RepositoryID, + WorktreeID: w.WorktreeID, + WorktreePath: w.WorktreePath, + WorktreeBranch: w.WorktreeBranch, + Position: i, + ErrorMessage: w.ErrorMessage, + } + if createErr := e.repo.CreateTaskEnvironmentRepo(ctx, row); createErr != nil { + e.logger.Warn("failed to persist task environment repo", + zap.String("env_id", envID), + zap.String("repository_id", w.RepositoryID), + zap.Error(createErr)) + } + } +} diff --git a/apps/backend/internal/orchestrator/executor/executor_execute_workspace_path_test.go b/apps/backend/internal/orchestrator/executor/executor_execute_workspace_path_test.go new file mode 100644 index 0000000000..a455fba93b --- /dev/null +++ b/apps/backend/internal/orchestrator/executor/executor_execute_workspace_path_test.go @@ -0,0 +1,52 @@ +package executor + +import "testing" + +// Pinpointed tests for computeWorkspacePath. The persisted workspace_path +// becomes agentctl's WorkDir on cold start (GetOrEnsureExecution); pointing it +// at the wrong directory disables the per-repo tracker fan-out and silently +// drops the Changes panel back to single-repo mode. +func TestResolveTaskEnvWorkspacePath(t *testing.T) { + t.Parallel() + t.Run("multi-repo keeps task root", func(t *testing.T) { + req := &LaunchAgentRequest{TaskDirName: "do-nothing_mvo"} + resp := &LaunchAgentResponse{ + // Lifecycle adapter mirrors agentctl WorkDir (task root) into + // the legacy WorktreePath field for multi-repo launches. + WorktreePath: "/tmp/tasks/do-nothing_mvo", + Worktrees: []RepoWorktreeResult{ + {WorktreePath: "/tmp/tasks/do-nothing_mvo/kandev"}, + {WorktreePath: "/tmp/tasks/do-nothing_mvo/thm"}, + }, + } + if got := computeWorkspacePath(req, resp); got != "/tmp/tasks/do-nothing_mvo" { + t.Fatalf("multi-repo: want /tmp/tasks/do-nothing_mvo, got %q", got) + } + }) + + t.Run("single-repo derives task root from repo subdir", func(t *testing.T) { + req := &LaunchAgentRequest{TaskDirName: "fix-thing_abc"} + resp := &LaunchAgentResponse{ + WorktreePath: "/tmp/tasks/fix-thing_abc/kandev", + } + if got := computeWorkspacePath(req, resp); got != "/tmp/tasks/fix-thing_abc" { + t.Fatalf("single-repo: want /tmp/tasks/fix-thing_abc, got %q", got) + } + }) + + t.Run("non-task-dir mode passes worktree path through", func(t *testing.T) { + req := &LaunchAgentRequest{} // TaskDirName empty + resp := &LaunchAgentResponse{WorktreePath: "/legacy/worktrees/xyz"} + if got := computeWorkspacePath(req, resp); got != "/legacy/worktrees/xyz" { + t.Fatalf("non-task-dir: want /legacy/worktrees/xyz, got %q", got) + } + }) + + t.Run("no worktree falls back to repository path", func(t *testing.T) { + req := &LaunchAgentRequest{RepositoryPath: "/repos/myrepo"} + resp := &LaunchAgentResponse{} // no WorktreePath + if got := computeWorkspacePath(req, resp); got != "/repos/myrepo" { + t.Fatalf("fallback: want /repos/myrepo, got %q", got) + } + }) +} diff --git a/apps/backend/internal/orchestrator/executor/executor_mocks_test.go b/apps/backend/internal/orchestrator/executor/executor_mocks_test.go index 6d23ddfcb9..19738fb188 100644 --- a/apps/backend/internal/orchestrator/executor/executor_mocks_test.go +++ b/apps/backend/internal/orchestrator/executor/executor_mocks_test.go @@ -174,14 +174,16 @@ func (m *mockAgentManager) WaitForAgentctlReady(ctx context.Context, sessionID s // mockRepository implements executorStore for testing type mockRepository struct { - mu sync.Mutex - sessions map[string]*models.TaskSession - tasks map[string]*models.Task - taskRepositories map[string]*models.TaskRepository - repositories map[string]*models.Repository - executors map[string]*models.Executor - executorsRunning map[string]*models.ExecutorRunning - taskEnvironments map[string]*models.TaskEnvironment + mu sync.Mutex + sessions map[string]*models.TaskSession + tasks map[string]*models.Task + taskRepositories map[string]*models.TaskRepository + repositories map[string]*models.Repository + executors map[string]*models.Executor + executorsRunning map[string]*models.ExecutorRunning + taskEnvironments map[string]*models.TaskEnvironment + taskEnvironmentRepos map[string][]*models.TaskEnvironmentRepo // env_id → rows + sessionWorktrees []*models.TaskSessionWorktree // Optional hook to inject behavior into GetTaskSession (e.g. simulate a // transient DB error); if nil, the default map lookup is used. @@ -197,13 +199,14 @@ type mockRepository struct { func newMockRepository() *mockRepository { return &mockRepository{ - sessions: make(map[string]*models.TaskSession), - tasks: make(map[string]*models.Task), - taskRepositories: make(map[string]*models.TaskRepository), - repositories: make(map[string]*models.Repository), - executors: make(map[string]*models.Executor), - executorsRunning: make(map[string]*models.ExecutorRunning), - taskEnvironments: make(map[string]*models.TaskEnvironment), + sessions: make(map[string]*models.TaskSession), + tasks: make(map[string]*models.Task), + taskRepositories: make(map[string]*models.TaskRepository), + repositories: make(map[string]*models.Repository), + executors: make(map[string]*models.Executor), + executorsRunning: make(map[string]*models.ExecutorRunning), + taskEnvironments: make(map[string]*models.TaskEnvironment), + taskEnvironmentRepos: make(map[string][]*models.TaskEnvironmentRepo), } } @@ -294,7 +297,8 @@ func (m *mockRepository) UpsertExecutorRunning(ctx context.Context, running *mod return nil } -func (m *mockRepository) CreateTaskSessionWorktree(ctx context.Context, worktree *models.TaskSessionWorktree) error { +func (m *mockRepository) CreateTaskSessionWorktree(_ context.Context, worktree *models.TaskSessionWorktree) error { + m.sessionWorktrees = append(m.sessionWorktrees, worktree) return nil } @@ -358,7 +362,19 @@ func (m *mockRepository) GetTaskRepository(ctx context.Context, id string) (*mod return nil, nil } func (m *mockRepository) ListTaskRepositories(ctx context.Context, taskID string) ([]*models.TaskRepository, error) { - return nil, nil + var out []*models.TaskRepository + for _, tr := range m.taskRepositories { + if tr.TaskID == taskID { + out = append(out, tr) + } + } + // Stable order by Position so callers (and tests) see deterministic results. + for i := 1; i < len(out); i++ { + for j := i; j > 0 && out[j].Position < out[j-1].Position; j-- { + out[j], out[j-1] = out[j-1], out[j] + } + } + return out, nil } func (m *mockRepository) ListTaskRepositoriesByTaskIDs(_ context.Context, _ []string) (map[string][]*models.TaskRepository, error) { return make(map[string][]*models.TaskRepository), nil @@ -612,19 +628,46 @@ func (m *mockRepository) ListEnvironments(ctx context.Context) ([]*models.Enviro } // Task environment operations -func (m *mockRepository) GetTaskEnvironmentByTaskID(ctx context.Context, taskID string) (*models.TaskEnvironment, error) { - return m.taskEnvironments[taskID], nil +func (m *mockRepository) GetTaskEnvironmentByTaskID(_ context.Context, taskID string) (*models.TaskEnvironment, error) { + for _, env := range m.taskEnvironments { + if env.TaskID == taskID { + return env, nil + } + } + return nil, nil } -func (m *mockRepository) CreateTaskEnvironment(ctx context.Context, env *models.TaskEnvironment) error { +func (m *mockRepository) CreateTaskEnvironment(_ context.Context, env *models.TaskEnvironment) error { + if env.ID == "" { + env.ID = "env-" + env.TaskID + } m.createTaskEnvironmentCalls = append(m.createTaskEnvironmentCalls, env) - m.taskEnvironments[env.TaskID] = env + m.taskEnvironments[env.ID] = env + for _, r := range env.Repos { + r.TaskEnvironmentID = env.ID + if r.ID == "" { + r.ID = env.ID + "-repo-" + r.RepositoryID + } + } + if len(env.Repos) > 0 { + m.taskEnvironmentRepos[env.ID] = append(m.taskEnvironmentRepos[env.ID], env.Repos...) + } return nil } -func (m *mockRepository) UpdateTaskEnvironment(ctx context.Context, env *models.TaskEnvironment) error { +func (m *mockRepository) UpdateTaskEnvironment(_ context.Context, env *models.TaskEnvironment) error { + if env.ID == "" { + return nil + } m.updateTaskEnvironmentCalls = append(m.updateTaskEnvironmentCalls, env) - m.taskEnvironments[env.TaskID] = env + m.taskEnvironments[env.ID] = env return nil } +func (m *mockRepository) CreateTaskEnvironmentRepo(_ context.Context, repo *models.TaskEnvironmentRepo) error { + m.taskEnvironmentRepos[repo.TaskEnvironmentID] = append(m.taskEnvironmentRepos[repo.TaskEnvironmentID], repo) + return nil +} +func (m *mockRepository) ListTaskEnvironmentRepos(_ context.Context, envID string) ([]*models.TaskEnvironmentRepo, error) { + return m.taskEnvironmentRepos[envID], nil +} // Task Plan operations func (m *mockRepository) CreateTaskPlan(ctx context.Context, plan *models.TaskPlan) error { return nil } diff --git a/apps/backend/internal/orchestrator/executor/executor_multi_repo_test.go b/apps/backend/internal/orchestrator/executor/executor_multi_repo_test.go new file mode 100644 index 0000000000..ed52456ee8 --- /dev/null +++ b/apps/backend/internal/orchestrator/executor/executor_multi_repo_test.go @@ -0,0 +1,204 @@ +package executor + +import ( + "context" + "testing" + "time" + + "github.com/kandev/kandev/internal/agent/lifecycle" + "github.com/kandev/kandev/internal/task/models" + v1 "github.com/kandev/kandev/pkg/api/v1" +) + +// seedMultiRepoTask wires the mock repository with two repositories linked to +// taskID, returning the captured launch request after LaunchPreparedSession. +func seedMultiRepoTask(t *testing.T, repo *mockRepository, taskID string) { + t.Helper() + repo.repositories["repo-front"] = &models.Repository{ + ID: "repo-front", + Name: "frontend", + LocalPath: "/repos/frontend", + WorktreeBranchPrefix: "feat/", + } + repo.repositories["repo-back"] = &models.Repository{ + ID: "repo-back", + Name: "backend", + LocalPath: "/repos/backend", + WorktreeBranchPrefix: "feat/", + } + repo.taskRepositories["tr-1"] = &models.TaskRepository{ + ID: "tr-1", TaskID: taskID, RepositoryID: "repo-front", Position: 0, BaseBranch: "main", + } + repo.taskRepositories["tr-2"] = &models.TaskRepository{ + ID: "tr-2", TaskID: taskID, RepositoryID: "repo-back", Position: 1, BaseBranch: "main", + } +} + +func TestLaunchPreparedSession_MultiRepo_PopulatesRequestRepositories(t *testing.T) { + repo := newMockRepository() + taskID := "task-multi-1" + sessionID := "session-multi-1" + seedMultiRepoTask(t, repo, taskID) + + repo.sessions[sessionID] = &models.TaskSession{ + ID: sessionID, + TaskID: taskID, + AgentProfileID: "profile-123", + State: models.TaskSessionStateCreated, + StartedAt: time.Now(), + UpdatedAt: time.Now(), + } + + var captured *LaunchAgentRequest + agentManager := &mockAgentManager{ + launchAgentFunc: func(_ context.Context, req *LaunchAgentRequest) (*LaunchAgentResponse, error) { + captured = req + return &LaunchAgentResponse{ + AgentExecutionID: "exec-1", + Worktrees: []RepoWorktreeResult{ + {RepositoryID: "repo-front", WorktreeID: "wt-front", WorktreeBranch: "feat/x-1", WorktreePath: "/tasks/x/frontend"}, + {RepositoryID: "repo-back", WorktreeID: "wt-back", WorktreeBranch: "feat/x-2", WorktreePath: "/tasks/x/backend"}, + }, + }, nil + }, + } + exec := newTestExecutor(t, agentManager, repo) + + task := &v1.Task{ID: taskID, WorkspaceID: "ws-1", Title: "Multi"} + _, err := exec.LaunchPreparedSession(context.Background(), task, sessionID, LaunchOptions{ + AgentProfileID: "profile-123", + StartAgent: false, + }) + if err != nil { + t.Fatalf("LaunchPreparedSession: %v", err) + } + + if captured == nil { + t.Fatal("expected launch agent to be called") + } + if len(captured.Repositories) != 2 { + t.Fatalf("expected req.Repositories length 2, got %d", len(captured.Repositories)) + } + if captured.Repositories[0].RepositoryID != "repo-front" || captured.Repositories[1].RepositoryID != "repo-back" { + t.Errorf("unexpected repo order: %+v", captured.Repositories) + } + // Legacy single-repo top-level fields stay populated from the primary. + if captured.RepositoryPath != "/repos/frontend" { + t.Errorf("expected primary repo path on top-level field, got %q", captured.RepositoryPath) + } +} + +func TestLaunchPreparedSession_MultiRepo_PersistsPerRepoEnvironmentAndWorktreeRows(t *testing.T) { + repo := newMockRepository() + taskID := "task-multi-2" + sessionID := "session-multi-2" + seedMultiRepoTask(t, repo, taskID) + + repo.sessions[sessionID] = &models.TaskSession{ + ID: sessionID, + TaskID: taskID, + AgentProfileID: "profile-123", + State: models.TaskSessionStateCreated, + StartedAt: time.Now(), + UpdatedAt: time.Now(), + } + + agentManager := &mockAgentManager{ + launchAgentFunc: func(_ context.Context, _ *LaunchAgentRequest) (*LaunchAgentResponse, error) { + return &LaunchAgentResponse{ + AgentExecutionID: "exec-2", + WorktreeID: "wt-front", // legacy mirror of Worktrees[0] + WorktreePath: "/tasks/x/frontend", + Worktrees: []RepoWorktreeResult{ + {RepositoryID: "repo-front", WorktreeID: "wt-front", WorktreeBranch: "feat/x-1", WorktreePath: "/tasks/x/frontend"}, + {RepositoryID: "repo-back", WorktreeID: "wt-back", WorktreeBranch: "feat/x-2", WorktreePath: "/tasks/x/backend"}, + }, + PrepareResult: &lifecycle.EnvPrepareResult{ + Success: true, + Worktrees: []lifecycle.RepoWorktreeResult{ + {RepositoryID: "repo-front", WorktreeID: "wt-front"}, + {RepositoryID: "repo-back", WorktreeID: "wt-back"}, + }, + }, + }, nil + }, + } + exec := newTestExecutor(t, agentManager, repo) + + task := &v1.Task{ID: taskID, WorkspaceID: "ws-1", Title: "Multi"} + _, err := exec.LaunchPreparedSession(context.Background(), task, sessionID, LaunchOptions{ + AgentProfileID: "profile-123", + StartAgent: false, + }) + if err != nil { + t.Fatalf("LaunchPreparedSession: %v", err) + } + + // One TaskEnvironment row + 2 TaskEnvironmentRepo rows. + if len(repo.taskEnvironments) != 1 { + t.Fatalf("expected 1 task_environment, got %d", len(repo.taskEnvironments)) + } + var envID string + for id := range repo.taskEnvironments { + envID = id + } + if got := len(repo.taskEnvironmentRepos[envID]); got != 2 { + t.Errorf("expected 2 task_environment_repos, got %d", got) + } + + // Two TaskSessionWorktree rows, one per repo. + if len(repo.sessionWorktrees) != 2 { + t.Fatalf("expected 2 session_worktree rows, got %d", len(repo.sessionWorktrees)) + } + repoIDsSeen := map[string]bool{} + for _, w := range repo.sessionWorktrees { + repoIDsSeen[w.RepositoryID] = true + } + if !repoIDsSeen["repo-front"] || !repoIDsSeen["repo-back"] { + t.Errorf("expected both repo IDs persisted; got %v", repoIDsSeen) + } +} + +func TestLaunchPreparedSession_SingleRepo_DoesNotPopulateRequestRepositories(t *testing.T) { + repo := newMockRepository() + taskID := "task-single-1" + sessionID := "session-single-1" + repo.repositories["repo-only"] = &models.Repository{ + ID: "repo-only", Name: "only", LocalPath: "/repos/only", WorktreeBranchPrefix: "feat/", + } + repo.taskRepositories["tr-only"] = &models.TaskRepository{ + ID: "tr-only", TaskID: taskID, RepositoryID: "repo-only", Position: 0, BaseBranch: "main", + } + repo.sessions[sessionID] = &models.TaskSession{ + ID: sessionID, + TaskID: taskID, + AgentProfileID: "profile-123", + State: models.TaskSessionStateCreated, + StartedAt: time.Now(), + UpdatedAt: time.Now(), + } + + var captured *LaunchAgentRequest + agentManager := &mockAgentManager{ + launchAgentFunc: func(_ context.Context, req *LaunchAgentRequest) (*LaunchAgentResponse, error) { + captured = req + return &LaunchAgentResponse{AgentExecutionID: "exec-3"}, nil + }, + } + exec := newTestExecutor(t, agentManager, repo) + + task := &v1.Task{ID: taskID, WorkspaceID: "ws-1"} + _, err := exec.LaunchPreparedSession(context.Background(), task, sessionID, LaunchOptions{ + AgentProfileID: "profile-123", + StartAgent: false, + }) + if err != nil { + t.Fatalf("LaunchPreparedSession: %v", err) + } + if len(captured.Repositories) != 0 { + t.Errorf("single-repo launch should not populate Repositories list; got %d entries", len(captured.Repositories)) + } + if captured.RepositoryPath != "/repos/only" { + t.Errorf("expected legacy RepositoryPath populated; got %q", captured.RepositoryPath) + } +} diff --git a/apps/backend/internal/orchestrator/executor/executor_resume.go b/apps/backend/internal/orchestrator/executor/executor_resume.go index cd3400b530..86f6b3310c 100644 --- a/apps/backend/internal/orchestrator/executor/executor_resume.go +++ b/apps/backend/internal/orchestrator/executor/executor_resume.go @@ -54,9 +54,43 @@ func (e *Executor) resolvePrimaryRepoInfo(ctx context.Context, taskID string) (* if primaryTaskRepo == nil { return info, nil } - info.RepositoryID = primaryTaskRepo.RepositoryID - info.BaseBranch = primaryTaskRepo.BaseBranch - info.CheckoutBranch = primaryTaskRepo.CheckoutBranch + return e.resolveTaskRepoInfo(ctx, primaryTaskRepo) +} + +// resolveAllRepoInfo returns the resolved repository info for every repository +// linked to the task, ordered by Position. Returns a single-element slice for +// single-repo tasks and an empty slice for repo-less tasks (e.g. quick chat). +// Each entry has LocalPath populated, cloning provider-backed repos on demand. +func (e *Executor) resolveAllRepoInfo(ctx context.Context, taskID string) ([]*repoInfo, error) { + taskRepos, err := e.repo.ListTaskRepositories(ctx, taskID) + if err != nil { + e.logger.Error("failed to list task repositories", + zap.String("task_id", taskID), + zap.Error(err)) + return nil, err + } + if len(taskRepos) == 0 { + return nil, nil + } + out := make([]*repoInfo, 0, len(taskRepos)) + for _, tr := range taskRepos { + info, resolveErr := e.resolveTaskRepoInfo(ctx, tr) + if resolveErr != nil { + return nil, resolveErr + } + out = append(out, info) + } + return out, nil +} + +// resolveTaskRepoInfo turns a TaskRepository row into a fully-resolved repoInfo +// (loads the Repository entity, clones if necessary, fills defaults). +func (e *Executor) resolveTaskRepoInfo(ctx context.Context, tr *models.TaskRepository) (*repoInfo, error) { + info := &repoInfo{ + RepositoryID: tr.RepositoryID, + BaseBranch: tr.BaseBranch, + CheckoutBranch: tr.CheckoutBranch, + } if info.RepositoryID == "" { return info, nil } @@ -207,6 +241,38 @@ func buildPrepareResultMetadata(result *lifecycle.EnvPrepareResult) map[string]i } func (e *Executor) persistWorktreeAssociation(ctx context.Context, taskID string, session *models.TaskSession, repositoryID string, resp *LaunchAgentResponse) { + // Multi-repo path: persist one TaskSessionWorktree row per per-repo worktree + // returned by the preparer. Each row carries its own RepositoryID so + // downstream lookups can scope by repo. + if len(resp.Worktrees) > 0 { + existingIDs := make(map[string]bool, len(session.Worktrees)) + for _, wt := range session.Worktrees { + existingIDs[wt.WorktreeID] = true + } + for i, w := range resp.Worktrees { + if w.WorktreeID == "" || existingIDs[w.WorktreeID] { + continue + } + row := &models.TaskSessionWorktree{ + SessionID: session.ID, + WorktreeID: w.WorktreeID, + RepositoryID: w.RepositoryID, + Position: i, + WorktreePath: w.WorktreePath, + WorktreeBranch: w.WorktreeBranch, + } + if err := e.repo.CreateTaskSessionWorktree(ctx, row); err != nil { + e.logger.Error("failed to persist session worktree association", + zap.String("task_id", taskID), + zap.String("session_id", session.ID), + zap.String("worktree_id", w.WorktreeID), + zap.String("repository_id", w.RepositoryID), + zap.Error(err)) + } + } + return + } + if resp.WorktreeID == "" { return } @@ -597,6 +663,25 @@ func (e *Executor) applyResumeRepoConfig(ctx context.Context, task *v1.Task, ses req.PullBeforeWorktree = repository.PullBeforeWorktree } + // Multi-repo: when the task has more than one repository, populate + // req.Repositories so the lifecycle preparer can resume/recreate each + // repo's worktree. The legacy top-level fields above stay populated + // from the primary for backwards compat. + if len(task.Repositories) > 1 { + allRepos, allErr := e.resolveAllRepoInfo(ctx, task.ID) + if allErr != nil { + return repositoryID, allErr + } + if len(allRepos) > 1 { + req.Repositories = buildRepoSpecs(allRepos) + // Stamp the per-repo TaskDirName so the preparer reuses the same + // task root that the original launch created. + if env, _ := e.repo.GetTaskEnvironmentByTaskID(ctx, task.ID); env != nil && env.TaskDirName != "" { + req.TaskDirName = env.TaskDirName + } + } + } + return repositoryID, nil } diff --git a/apps/backend/internal/orchestrator/executor/executor_test.go b/apps/backend/internal/orchestrator/executor/executor_test.go index 7fbe9b4c01..65250c4910 100644 --- a/apps/backend/internal/orchestrator/executor/executor_test.go +++ b/apps/backend/internal/orchestrator/executor/executor_test.go @@ -3,6 +3,7 @@ package executor import ( "context" "fmt" + "sync" "sync/atomic" "testing" "time" @@ -800,3 +801,98 @@ func TestPersistResumeState_SetsStartingState(t *testing.T) { } }) } + +// Regression: PrepareTaskSession launches the workspace in a background +// goroutine while StartTaskWithSession runs a foreground launch when the +// agent is started. Both call LaunchPreparedSession on the same session. +// Without per-session serialisation the two launches both reach +// agentManager.LaunchAgent and the second one errors with +// "race resolved during register" after running env prep — surfacing as +// "Environment setup failed" in the UI. Multi-repo amplifies the window +// because env prep runs sequentially per repo. +func TestLaunchPreparedSession_SerialisesConcurrentLaunches(t *testing.T) { + repo := newMockRepository() + session := &models.TaskSession{ + ID: "session-race", + TaskID: "task-race", + AgentProfileID: "profile-1", + State: models.TaskSessionStateCreated, + StartedAt: time.Now(), + UpdatedAt: time.Now(), + } + repo.sessions[session.ID] = session + + // `entered` fires every time LaunchAgent is hit. `gate` blocks the + // caller until the test releases it, keeping the first launch holding + // the per-session lock so a parallel call would otherwise race in. + // Channel-based coordination per CLAUDE.md (no time.Sleep). + var launchCount int64 + entered := make(chan struct{}, 2) + gate := make(chan struct{}) + agentManager := &mockAgentManager{ + launchAgentFunc: func(ctx context.Context, req *LaunchAgentRequest) (*LaunchAgentResponse, error) { + atomic.AddInt64(&launchCount, 1) + entered <- struct{}{} + <-gate + // Simulate the first caller persisting the agent execution id — + // the second caller's re-fetch under the lock will see it and + // take the fast path instead of launching again. + repo.sessions[req.SessionID].AgentExecutionID = "exec-race" + return &LaunchAgentResponse{AgentExecutionID: "exec-race", Status: v1.AgentStatusStarting}, nil + }, + // Fast path lookup must succeed for the second caller; mirror what + // the live store would return after the first caller registered. + getExecutionIDForSessionFunc: func(ctx context.Context, sessionID string) (string, error) { + return "exec-race", nil + }, + } + executor := newTestExecutor(t, agentManager, repo) + task := &v1.Task{ID: "task-race", WorkspaceID: "ws-1"} + + var wg sync.WaitGroup + results := make([]error, 2) + for i := range results { + wg.Add(1) + go func(idx int) { + defer wg.Done() + _, err := executor.LaunchPreparedSession(context.Background(), task, "session-race", LaunchOptions{ + AgentProfileID: "profile-1", + StartAgent: true, + }) + results[idx] = err + }(i) + } + + // Wait for the first launch to enter LaunchAgent and block on the + // gate. With the per-session lock the second goroutine cannot proceed + // past the lock acquisition, so a second `entered` send must NOT + // happen within a bounded window. Without the lock, both goroutines + // race in well within the timeout (~ms). + <-entered + select { + case <-entered: + close(gate) + wg.Wait() + t.Fatalf("LaunchAgent entered twice — second caller raced past the per-session lock") + case <-time.After(100 * time.Millisecond): + // No second entry = the lock is holding the second caller. + } + if got := atomic.LoadInt64(&launchCount); got != 1 { + close(gate) + wg.Wait() + t.Fatalf("LaunchAgent call count before gate release = %d, want 1 (lock should have serialised both)", got) + } + close(gate) + wg.Wait() + + // First call ran LaunchAgent; second call took the fast path so total + // stays at 1. Both return non-error (the second is a no-op start). + if got := atomic.LoadInt64(&launchCount); got != 1 { + t.Errorf("LaunchAgent total calls = %d, want 1", got) + } + for i, err := range results { + if err != nil { + t.Errorf("results[%d] = %v, want nil", i, err) + } + } +} diff --git a/apps/backend/internal/orchestrator/service.go b/apps/backend/internal/orchestrator/service.go index e89717edb7..3e2b59c3d1 100644 --- a/apps/backend/internal/orchestrator/service.go +++ b/apps/backend/internal/orchestrator/service.go @@ -118,6 +118,7 @@ type repoStore interface { // Additional methods needed by executor UpdateTaskState(ctx context.Context, id string, state v1.TaskState) error GetPrimaryTaskRepository(ctx context.Context, taskID string) (*models.TaskRepository, error) + ListTaskRepositories(ctx context.Context, taskID string) ([]*models.TaskRepository, error) CreateTaskSession(ctx context.Context, session *models.TaskSession) error UpdateTaskSession(ctx context.Context, session *models.TaskSession) error ListActiveTaskSessions(ctx context.Context) ([]*models.TaskSession, error) @@ -127,6 +128,9 @@ type repoStore interface { GetRepository(ctx context.Context, id string) (*models.Repository, error) UpdateRepository(ctx context.Context, repository *models.Repository) error GetExecutorProfile(ctx context.Context, id string) (*models.ExecutorProfile, error) + // Multi-repo task environment children + CreateTaskEnvironmentRepo(ctx context.Context, repo *models.TaskEnvironmentRepo) error + ListTaskEnvironmentRepos(ctx context.Context, envID string) ([]*models.TaskEnvironmentRepo, error) // Session history + plan (for context handover) GetTaskPlan(ctx context.Context, taskID string) (*models.TaskPlan, error) } @@ -226,7 +230,12 @@ type Service struct { // Clarification canceller — cancels pending clarifications when agent's turn completes clarificationCanceller ClarificationCanceller - // Push tracker: sessionID -> last known ahead count + // Push tracker: "|" -> last known ahead count. + // The repository_name segment is required for multi-repo: each repo emits + // its own git status events, and keying by sessionID alone made events + // from different repos overwrite each other's ahead counts (so only one + // push got detected per session). Single-repo / repo-less sessions key + // against an empty repository_name. pushTracker sync.Map // gitSnapshotCache throttles per-session writes of the live git status diff --git a/apps/backend/internal/orchestrator/task_operations.go b/apps/backend/internal/orchestrator/task_operations.go index fe3d0ae7fd..7d009c1954 100644 --- a/apps/backend/internal/orchestrator/task_operations.go +++ b/apps/backend/internal/orchestrator/task_operations.go @@ -1308,6 +1308,9 @@ func (s *Service) DeleteSession(ctx context.Context, sessionID string) error { if s.gitSnapshotCache != nil { s.gitSnapshotCache.forget(sessionID) } + // Same reasoning for the push-detection tracker. Multi-repo sessions + // accumulate one entry per repo; pushTrackerForget walks them all. + s.pushTrackerForget(sessionID) // Auto-promote another session if we deleted the primary if wasPrimary { diff --git a/apps/backend/internal/task/handlers/process_handlers_test.go b/apps/backend/internal/task/handlers/process_handlers_test.go index fd9760879f..75d779082d 100644 --- a/apps/backend/internal/task/handlers/process_handlers_test.go +++ b/apps/backend/internal/task/handlers/process_handlers_test.go @@ -355,6 +355,21 @@ func (m *mockRepository) DeleteTaskEnvironment(ctx context.Context, id string) e func (m *mockRepository) DeleteTaskEnvironmentsByTask(ctx context.Context, taskID string) error { return nil } +func (m *mockRepository) CreateTaskEnvironmentRepo(ctx context.Context, repo *models.TaskEnvironmentRepo) error { + return nil +} +func (m *mockRepository) ListTaskEnvironmentRepos(ctx context.Context, envID string) ([]*models.TaskEnvironmentRepo, error) { + return nil, nil +} +func (m *mockRepository) UpdateTaskEnvironmentRepo(ctx context.Context, repo *models.TaskEnvironmentRepo) error { + return nil +} +func (m *mockRepository) DeleteTaskEnvironmentRepo(ctx context.Context, id string) error { + return nil +} +func (m *mockRepository) DeleteTaskEnvironmentReposByEnv(ctx context.Context, envID string) error { + return nil +} // Git Snapshot operations func (m *mockRepository) CreateGitSnapshot(ctx context.Context, snapshot *models.GitSnapshot) error { diff --git a/apps/backend/internal/task/handlers/repository_handlers.go b/apps/backend/internal/task/handlers/repository_handlers.go index 3971517169..25a020d687 100644 --- a/apps/backend/internal/task/handlers/repository_handlers.go +++ b/apps/backend/internal/task/handlers/repository_handlers.go @@ -39,7 +39,13 @@ func (h *RepositoryHandlers) registerHTTP(router *gin.Engine) { api.GET("/workspaces/:id/repositories", h.httpListRepositories) api.POST("/workspaces/:id/repositories", h.httpCreateRepository) api.GET("/workspaces/:id/repositories/discover", h.httpDiscoverRepositories) - api.GET("/workspaces/:id/repositories/branches", h.httpListLocalRepositoryBranches) + // Unified branch listing — accepts either ?repository_id= for an imported + // workspace repo, or ?path= for an on-machine folder discovered but not + // yet imported. Both paths bottom out in `listGitBranches`; the only + // difference is how the absolute path is resolved. + api.GET("/workspaces/:id/branches", h.httpListBranches) + // Local-status (branch + dirty files) backs the fresh-branch consent + // flow on the local executor. Path-only — fresh-branch is local-only. api.GET("/workspaces/:id/repositories/local-status", h.httpLocalRepositoryStatus) api.GET("/workspaces/:id/repositories/validate", h.httpValidateRepositoryPath) api.GET("/repositories/:id", h.httpGetRepository) @@ -152,35 +158,43 @@ func (h *RepositoryHandlers) httpValidateRepositoryPath(c *gin.Context) { }) } -func (h *RepositoryHandlers) httpListLocalRepositoryBranches(c *gin.Context) { +// httpListBranches handles the unified branch endpoint: +// +// GET /api/v1/workspaces/:id/branches?repository_id=X +// GET /api/v1/workspaces/:id/branches?path=/abs/path +// +// Exactly one of the two query params must be set. Both bottom out in the +// same `listGitBranches` call; the difference is just where the absolute +// path is resolved from (DB row vs request param). +func (h *RepositoryHandlers) httpListBranches(c *gin.Context) { + repoID := c.Query("repository_id") path := c.Query("path") - if path == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "path is required"}) + if repoID == "" && path == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "repository_id or path is required"}) return } - branches, err := h.service.ListLocalRepositoryBranches(c.Request.Context(), path) + if repoID != "" && path != "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "specify only one of repository_id or path"}) + return + } + result, err := h.service.ListBranchesWithCurrent(c.Request.Context(), repoID, path) if err != nil { if errors.Is(err, service.ErrPathNotAllowed) { c.JSON(http.StatusBadRequest, gin.H{"error": "path is not within allowed roots"}) return } - h.logger.Error("failed to list local repository branches", zap.Error(err)) - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list local repository branches"}) + h.logger.Error("failed to list branches", zap.Error(err)) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list branches"}) return } - current, err := h.service.LocalRepositoryCurrentBranch(c.Request.Context(), path) - if err != nil { - // Branches succeeded — current branch is best-effort metadata for the UI. - h.logger.Debug("failed to read current branch", zap.Error(err)) - } - dtoBranches := make([]dto.BranchDTO, len(branches)) - for i, branch := range branches { + dtoBranches := make([]dto.BranchDTO, len(result.Branches)) + for i, branch := range result.Branches { dtoBranches[i] = dto.FromBranch(branch) } c.JSON(http.StatusOK, dto.RepositoryBranchesResponse{ Branches: dtoBranches, Total: len(dtoBranches), - CurrentBranch: current, + CurrentBranch: result.CurrentBranch, }) } @@ -288,7 +302,8 @@ func (h *RepositoryHandlers) httpListRepositoryBranches(c *gin.Context) { fetchedAt, fetchError = h.refreshBranchesAtPath(ctx, repoID, repo.LocalPath) } - branches, err := h.service.ListLocalRepositoryBranches(ctx, repo.LocalPath) + // Use the unified branch lister (HEAD's API) with empty repoID + path. + branches, err := h.service.ListBranches(ctx, "", repo.LocalPath) if err != nil { if errors.Is(err, service.ErrPathNotAllowed) { c.JSON(http.StatusBadRequest, gin.H{"error": "repository path is not allowed"}) diff --git a/apps/backend/internal/task/models/models.go b/apps/backend/internal/task/models/models.go index e68257c5b4..2f8ac0093f 100644 --- a/apps/backend/internal/task/models/models.go +++ b/apps/backend/internal/task/models/models.go @@ -545,7 +545,7 @@ const ( type TaskEnvironment struct { ID string `json:"id"` TaskID string `json:"task_id"` - RepositoryID string `json:"repository_id"` + RepositoryID string `json:"repository_id"` // Deprecated: use Repos. Kept for dual-write/backwards compat. ExecutorType string `json:"executor_type"` ExecutorID string `json:"executor_id"` ExecutorProfileID string `json:"executor_profile_id"` @@ -553,18 +553,57 @@ type TaskEnvironment struct { ControlPort int `json:"control_port"` // agentctl control port Status TaskEnvironmentStatus `json:"status"` - // Type-specific fields - WorktreeID string `json:"worktree_id,omitempty"` - WorktreePath string `json:"worktree_path,omitempty"` - WorktreeBranch string `json:"worktree_branch,omitempty"` + // Type-specific fields. The single worktree fields below are legacy: with + // multi-repo tasks, the per-repo worktrees live on Repos. WorkspacePath + // continues to point at the agent workspace root (the task root when + // TaskDirName is set, otherwise the single repo's worktree path). + WorktreeID string `json:"worktree_id,omitempty"` // Deprecated: use Repos[i].WorktreeID + WorktreePath string `json:"worktree_path,omitempty"` // Deprecated: use Repos[i].WorktreePath + WorktreeBranch string `json:"worktree_branch,omitempty"` // Deprecated: use Repos[i].WorktreeBranch WorkspacePath string `json:"workspace_path,omitempty"` ContainerID string `json:"container_id,omitempty"` SandboxID string `json:"sandbox_id,omitempty"` + // TaskDirName is the semantic directory name for the task (e.g. "fix-bug_ab12"). + // Set when the task uses the multi-repo task-directory layout + // (~/.kandev/tasks/{TaskDirName}/{RepoName}/). + TaskDirName string `json:"task_dir_name,omitempty"` + + // Repos contains one entry per repository associated with this environment. + // Populated by repository getters. Empty for environments created before the + // multi-repo backfill ran with no legacy repository_id. + Repos []*TaskEnvironmentRepo `json:"repos,omitempty"` + CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } +// RepoFor returns the per-repo environment row for repositoryID, or nil if not present. +func (te *TaskEnvironment) RepoFor(repositoryID string) *TaskEnvironmentRepo { + for _, r := range te.Repos { + if r.RepositoryID == repositoryID { + return r + } + } + return nil +} + +// TaskEnvironmentRepo represents the per-repository state of a task environment. +// One row per repository associated with the task: each carries its own worktree +// reference and any per-repo preparation error. +type TaskEnvironmentRepo struct { + ID string `json:"id"` + TaskEnvironmentID string `json:"task_environment_id"` + RepositoryID string `json:"repository_id"` + WorktreeID string `json:"worktree_id,omitempty"` + WorktreePath string `json:"worktree_path,omitempty"` + WorktreeBranch string `json:"worktree_branch,omitempty"` + Position int `json:"position"` + ErrorMessage string `json:"error_message,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + // ToAPI converts internal TaskEnvironment to API map. func (te *TaskEnvironment) ToAPI() map[string]interface{} { result := map[string]interface{}{ @@ -600,9 +639,44 @@ func (te *TaskEnvironment) ToAPI() map[string]interface{} { if te.SandboxID != "" { result["sandbox_id"] = te.SandboxID } + if te.TaskDirName != "" { + result["task_dir_name"] = te.TaskDirName + } + if len(te.Repos) > 0 { + repos := make([]map[string]interface{}, 0, len(te.Repos)) + for _, repo := range te.Repos { + repos = append(repos, repo.ToAPI()) + } + result["repos"] = repos + } return result } +// ToAPI converts internal TaskEnvironmentRepo to API map. +func (r *TaskEnvironmentRepo) ToAPI() map[string]interface{} { + out := map[string]interface{}{ + "id": r.ID, + "task_environment_id": r.TaskEnvironmentID, + "repository_id": r.RepositoryID, + "position": r.Position, + "created_at": r.CreatedAt, + "updated_at": r.UpdatedAt, + } + if r.WorktreeID != "" { + out["worktree_id"] = r.WorktreeID + } + if r.WorktreePath != "" { + out["worktree_path"] = r.WorktreePath + } + if r.WorktreeBranch != "" { + out["worktree_branch"] = r.WorktreeBranch + } + if r.ErrorMessage != "" { + out["error_message"] = r.ErrorMessage + } + return out +} + // TaskPlan represents a plan associated with a task type TaskPlan struct { ID string `json:"id"` diff --git a/apps/backend/internal/task/repository/interface.go b/apps/backend/internal/task/repository/interface.go index d2a5fac960..30d4afd73f 100644 --- a/apps/backend/internal/task/repository/interface.go +++ b/apps/backend/internal/task/repository/interface.go @@ -181,7 +181,8 @@ type EnvironmentRepository interface { ListEnvironments(ctx context.Context) ([]*models.Environment, error) } -// TaskEnvironmentRepository handles per-task execution environment instances. +// TaskEnvironmentRepository handles per-task execution environment instances +// and their per-repository child rows. type TaskEnvironmentRepository interface { CreateTaskEnvironment(ctx context.Context, env *models.TaskEnvironment) error GetTaskEnvironment(ctx context.Context, id string) (*models.TaskEnvironment, error) @@ -189,6 +190,11 @@ type TaskEnvironmentRepository interface { UpdateTaskEnvironment(ctx context.Context, env *models.TaskEnvironment) error DeleteTaskEnvironment(ctx context.Context, id string) error DeleteTaskEnvironmentsByTask(ctx context.Context, taskID string) error + CreateTaskEnvironmentRepo(ctx context.Context, repo *models.TaskEnvironmentRepo) error + ListTaskEnvironmentRepos(ctx context.Context, envID string) ([]*models.TaskEnvironmentRepo, error) + UpdateTaskEnvironmentRepo(ctx context.Context, repo *models.TaskEnvironmentRepo) error + DeleteTaskEnvironmentRepo(ctx context.Context, id string) error + DeleteTaskEnvironmentReposByEnv(ctx context.Context, envID string) error } // ReviewRepository handles session file review records. diff --git a/apps/backend/internal/task/repository/sqlite/base.go b/apps/backend/internal/task/repository/sqlite/base.go index e422e48239..0d5a443228 100644 --- a/apps/backend/internal/task/repository/sqlite/base.go +++ b/apps/backend/internal/task/repository/sqlite/base.go @@ -109,6 +109,9 @@ func (r *Repository) initSchema() error { if err := r.backfillTaskEnvironments(); err != nil { return err } + if err := r.backfillTaskEnvironmentRepos(); err != nil { + return err + } if err := r.healTaskEnvironmentWorkspacePaths(); err != nil { return err } @@ -172,6 +175,8 @@ func (r *Repository) runMigrations() error { _, _ = r.db.Exec(`ALTER TABLE workflows ADD COLUMN sort_order INTEGER NOT NULL DEFAULT 0`) // Add agent_profile_id column to workflows for per-workflow agent profile override (ignore error if already exists) _, _ = r.db.Exec(`ALTER TABLE workflows ADD COLUMN agent_profile_id TEXT DEFAULT ''`) + // Add task_dir_name column to task_environments for multi-repo task root layout (ignore error if already exists) + _, _ = r.db.Exec(`ALTER TABLE task_environments ADD COLUMN task_dir_name TEXT DEFAULT ''`) // Add hidden flag to workflows for system-only flows excluded from management UI (ignore error if already exists) _, _ = r.db.Exec(`ALTER TABLE workflows ADD COLUMN hidden INTEGER NOT NULL DEFAULT 0`) return nil @@ -608,6 +613,67 @@ func (r *Repository) backfillSingleTask(tx *sql.Tx, row backfillRow) error { return nil } +// backfillTaskEnvironmentRepos populates task_environment_repos from the legacy +// single-repo fields on task_environments. One row per environment that has a +// non-empty repository_id and no existing task_environment_repos row. +// Idempotent. +func (r *Repository) backfillTaskEnvironmentRepos() error { + rows, err := r.db.Query(` + SELECT te.id, + te.repository_id, + COALESCE(te.worktree_id, ''), + COALESCE(te.worktree_path, ''), + COALESCE(te.worktree_branch, ''), + te.created_at + FROM task_environments te + LEFT JOIN task_environment_repos ter ON ter.task_environment_id = te.id + WHERE te.repository_id != '' AND ter.id IS NULL + `) + if err != nil { + return fmt.Errorf("backfill repos: query: %w", err) + } + defer func() { _ = rows.Close() }() + + type envRepoRow struct { + envID, repoID, wtID, wtPath, wtBranch, createdAt string + } + var pending []envRepoRow + for rows.Next() { + var row envRepoRow + if err := rows.Scan(&row.envID, &row.repoID, &row.wtID, &row.wtPath, &row.wtBranch, &row.createdAt); err != nil { + return fmt.Errorf("backfill repos: scan: %w", err) + } + pending = append(pending, row) + } + if err := rows.Err(); err != nil { + return fmt.Errorf("backfill repos: rows: %w", err) + } + if len(pending) == 0 { + return nil + } + + tx, err := r.db.Begin() + if err != nil { + return fmt.Errorf("backfill repos: begin tx: %w", err) + } + defer tx.Rollback() //nolint:errcheck + + for _, row := range pending { + if _, err := tx.Exec(` + INSERT INTO task_environment_repos ( + id, task_environment_id, repository_id, + worktree_id, worktree_path, worktree_branch, + position, error_message, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, 0, '', ?, ?) + `, uuid.New().String(), row.envID, row.repoID, + row.wtID, row.wtPath, row.wtBranch, + row.createdAt, row.createdAt); err != nil { + return fmt.Errorf("backfill repos: insert env %s: %w", row.envID, err) + } + } + return tx.Commit() +} + func (r *Repository) initCoreSchema() error { if err := r.initInfraSchema(); err != nil { return err @@ -987,6 +1053,7 @@ func (r *Repository) initSessionWorktreeSchema() error { workspace_path TEXT DEFAULT '', container_id TEXT DEFAULT '', sandbox_id TEXT DEFAULT '', + task_dir_name TEXT DEFAULT '', created_at TIMESTAMP NOT NULL, updated_at TIMESTAMP NOT NULL, FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE @@ -995,6 +1062,24 @@ func (r *Repository) initSessionWorktreeSchema() error { CREATE INDEX IF NOT EXISTS idx_task_environments_task_id ON task_environments(task_id); CREATE INDEX IF NOT EXISTS idx_task_environments_status ON task_environments(status); + CREATE TABLE IF NOT EXISTS task_environment_repos ( + id TEXT PRIMARY KEY, + task_environment_id TEXT NOT NULL, + repository_id TEXT NOT NULL, + worktree_id TEXT DEFAULT '', + worktree_path TEXT DEFAULT '', + worktree_branch TEXT DEFAULT '', + position INTEGER DEFAULT 0, + error_message TEXT DEFAULT '', + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL, + FOREIGN KEY (task_environment_id) REFERENCES task_environments(id) ON DELETE CASCADE, + UNIQUE(task_environment_id, repository_id) + ); + + CREATE INDEX IF NOT EXISTS idx_task_environment_repos_env_id ON task_environment_repos(task_environment_id); + CREATE INDEX IF NOT EXISTS idx_task_environment_repos_repository_id ON task_environment_repos(repository_id); + CREATE TABLE IF NOT EXISTS task_session_worktrees ( id TEXT PRIMARY KEY, session_id TEXT NOT NULL, diff --git a/apps/backend/internal/task/repository/sqlite/task_environment.go b/apps/backend/internal/task/repository/sqlite/task_environment.go index 4326a61df6..724c0354ef 100644 --- a/apps/backend/internal/task/repository/sqlite/task_environment.go +++ b/apps/backend/internal/task/repository/sqlite/task_environment.go @@ -12,6 +12,7 @@ import ( ) // CreateTaskEnvironment creates a new task environment record. +// If env.Repos is non-empty, the per-repo rows are inserted in the same transaction. func (r *Repository) CreateTaskEnvironment(ctx context.Context, env *models.TaskEnvironment) error { if env.ID == "" { env.ID = uuid.New().String() @@ -27,25 +28,41 @@ func (r *Repository) CreateTaskEnvironment(ctx context.Context, env *models.Task env.CreatedAt = now env.UpdatedAt = now - _, err := r.db.ExecContext(ctx, r.db.Rebind(` + tx, err := r.db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer func() { _ = tx.Rollback() }() + + if _, err := tx.ExecContext(ctx, r.db.Rebind(` INSERT INTO task_environments ( id, task_id, repository_id, executor_type, executor_id, executor_profile_id, agent_execution_id, control_port, status, worktree_id, worktree_path, worktree_branch, workspace_path, - container_id, sandbox_id, + container_id, sandbox_id, task_dir_name, created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `), env.ID, env.TaskID, env.RepositoryID, env.ExecutorType, env.ExecutorID, env.ExecutorProfileID, env.AgentExecutionID, env.ControlPort, string(env.Status), env.WorktreeID, env.WorktreePath, env.WorktreeBranch, env.WorkspacePath, - env.ContainerID, env.SandboxID, + env.ContainerID, env.SandboxID, env.TaskDirName, env.CreatedAt, env.UpdatedAt, - ) - return err + ); err != nil { + return err + } + + for _, repo := range env.Repos { + repo.TaskEnvironmentID = env.ID + if err := r.insertTaskEnvironmentRepoTx(ctx, tx, repo); err != nil { + return err + } + } + + return tx.Commit() } -// GetTaskEnvironment retrieves a task environment by ID. +// GetTaskEnvironment retrieves a task environment by ID, including per-repo rows. func (r *Repository) GetTaskEnvironment(ctx context.Context, id string) (*models.TaskEnvironment, error) { env := &models.TaskEnvironment{} var status string @@ -54,14 +71,14 @@ func (r *Repository) GetTaskEnvironment(ctx context.Context, id string) (*models SELECT id, task_id, repository_id, executor_type, executor_id, executor_profile_id, agent_execution_id, control_port, status, worktree_id, worktree_path, worktree_branch, workspace_path, - container_id, sandbox_id, + container_id, sandbox_id, COALESCE(task_dir_name, ''), created_at, updated_at FROM task_environments WHERE id = ? `), id).Scan( &env.ID, &env.TaskID, &env.RepositoryID, &env.ExecutorType, &env.ExecutorID, &env.ExecutorProfileID, &env.AgentExecutionID, &env.ControlPort, &status, &env.WorktreeID, &env.WorktreePath, &env.WorktreeBranch, &env.WorkspacePath, - &env.ContainerID, &env.SandboxID, + &env.ContainerID, &env.SandboxID, &env.TaskDirName, &env.CreatedAt, &env.UpdatedAt, ) if err == sql.ErrNoRows { @@ -71,10 +88,17 @@ func (r *Repository) GetTaskEnvironment(ctx context.Context, id string) (*models return nil, err } env.Status = models.TaskEnvironmentStatus(status) + + repos, err := r.ListTaskEnvironmentRepos(ctx, env.ID) + if err != nil { + return nil, err + } + env.Repos = repos return env, nil } -// GetTaskEnvironmentByTaskID retrieves the active task environment for a task. +// GetTaskEnvironmentByTaskID retrieves the active task environment for a task, +// including per-repo rows. func (r *Repository) GetTaskEnvironmentByTaskID(ctx context.Context, taskID string) (*models.TaskEnvironment, error) { env := &models.TaskEnvironment{} var status string @@ -83,14 +107,14 @@ func (r *Repository) GetTaskEnvironmentByTaskID(ctx context.Context, taskID stri SELECT id, task_id, repository_id, executor_type, executor_id, executor_profile_id, agent_execution_id, control_port, status, worktree_id, worktree_path, worktree_branch, workspace_path, - container_id, sandbox_id, + container_id, sandbox_id, COALESCE(task_dir_name, ''), created_at, updated_at FROM task_environments WHERE task_id = ? ORDER BY created_at DESC LIMIT 1 `), taskID).Scan( &env.ID, &env.TaskID, &env.RepositoryID, &env.ExecutorType, &env.ExecutorID, &env.ExecutorProfileID, &env.AgentExecutionID, &env.ControlPort, &status, &env.WorktreeID, &env.WorktreePath, &env.WorktreeBranch, &env.WorkspacePath, - &env.ContainerID, &env.SandboxID, + &env.ContainerID, &env.SandboxID, &env.TaskDirName, &env.CreatedAt, &env.UpdatedAt, ) if err == sql.ErrNoRows { @@ -100,10 +124,17 @@ func (r *Repository) GetTaskEnvironmentByTaskID(ctx context.Context, taskID stri return nil, err } env.Status = models.TaskEnvironmentStatus(status) + + repos, err := r.ListTaskEnvironmentRepos(ctx, env.ID) + if err != nil { + return nil, err + } + env.Repos = repos return env, nil } // UpdateTaskEnvironment updates an existing task environment. +// Per-repo rows are not touched; use the TaskEnvironmentRepo CRUD methods. func (r *Repository) UpdateTaskEnvironment(ctx context.Context, env *models.TaskEnvironment) error { // Refuse to clear workspace_path on a worktree-mode env. Same rationale // as CreateTaskEnvironment: empty workspace_path produces permanent 503 @@ -118,14 +149,14 @@ func (r *Repository) UpdateTaskEnvironment(ctx context.Context, env *models.Task repository_id = ?, executor_type = ?, executor_id = ?, executor_profile_id = ?, agent_execution_id = ?, control_port = ?, status = ?, worktree_id = ?, worktree_path = ?, worktree_branch = ?, workspace_path = ?, - container_id = ?, sandbox_id = ?, + container_id = ?, sandbox_id = ?, task_dir_name = ?, updated_at = ? WHERE id = ? `), env.RepositoryID, env.ExecutorType, env.ExecutorID, env.ExecutorProfileID, env.AgentExecutionID, env.ControlPort, string(env.Status), env.WorktreeID, env.WorktreePath, env.WorktreeBranch, env.WorkspacePath, - env.ContainerID, env.SandboxID, + env.ContainerID, env.SandboxID, env.TaskDirName, env.UpdatedAt, env.ID, ) @@ -140,6 +171,7 @@ func (r *Repository) UpdateTaskEnvironment(ctx context.Context, env *models.Task } // DeleteTaskEnvironment deletes a task environment by ID. +// Per-repo rows are removed via ON DELETE CASCADE. func (r *Repository) DeleteTaskEnvironment(ctx context.Context, id string) error { result, err := r.db.ExecContext(ctx, r.db.Rebind(` DELETE FROM task_environments WHERE id = ? @@ -161,3 +193,135 @@ func (r *Repository) DeleteTaskEnvironmentsByTask(ctx context.Context, taskID st `), taskID) return err } + +// CreateTaskEnvironmentRepo inserts a single per-repo environment row. +func (r *Repository) CreateTaskEnvironmentRepo(ctx context.Context, repo *models.TaskEnvironmentRepo) error { + if repo.TaskEnvironmentID == "" { + return fmt.Errorf("task_environment_id is required") + } + if repo.RepositoryID == "" { + return fmt.Errorf("repository_id is required") + } + if repo.ID == "" { + repo.ID = uuid.New().String() + } + now := time.Now().UTC() + repo.CreatedAt = now + repo.UpdatedAt = now + + _, err := r.db.ExecContext(ctx, r.db.Rebind(` + INSERT INTO task_environment_repos ( + id, task_environment_id, repository_id, + worktree_id, worktree_path, worktree_branch, + position, error_message, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `), + repo.ID, repo.TaskEnvironmentID, repo.RepositoryID, + repo.WorktreeID, repo.WorktreePath, repo.WorktreeBranch, + repo.Position, repo.ErrorMessage, repo.CreatedAt, repo.UpdatedAt, + ) + return err +} + +// insertTaskEnvironmentRepoTx is the in-transaction variant of CreateTaskEnvironmentRepo. +func (r *Repository) insertTaskEnvironmentRepoTx(ctx context.Context, tx *sql.Tx, repo *models.TaskEnvironmentRepo) error { + if repo.RepositoryID == "" { + return fmt.Errorf("repository_id is required") + } + if repo.ID == "" { + repo.ID = uuid.New().String() + } + now := time.Now().UTC() + repo.CreatedAt = now + repo.UpdatedAt = now + + _, err := tx.ExecContext(ctx, r.db.Rebind(` + INSERT INTO task_environment_repos ( + id, task_environment_id, repository_id, + worktree_id, worktree_path, worktree_branch, + position, error_message, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `), + repo.ID, repo.TaskEnvironmentID, repo.RepositoryID, + repo.WorktreeID, repo.WorktreePath, repo.WorktreeBranch, + repo.Position, repo.ErrorMessage, repo.CreatedAt, repo.UpdatedAt, + ) + return err +} + +// ListTaskEnvironmentRepos returns all per-repo rows for an environment, ordered by position. +func (r *Repository) ListTaskEnvironmentRepos(ctx context.Context, envID string) ([]*models.TaskEnvironmentRepo, error) { + rows, err := r.ro.QueryContext(ctx, r.ro.Rebind(` + SELECT id, task_environment_id, repository_id, + worktree_id, worktree_path, worktree_branch, + position, error_message, created_at, updated_at + FROM task_environment_repos + WHERE task_environment_id = ? + ORDER BY position ASC, created_at ASC + `), envID) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() + + var out []*models.TaskEnvironmentRepo + for rows.Next() { + repo := &models.TaskEnvironmentRepo{} + if err := rows.Scan( + &repo.ID, &repo.TaskEnvironmentID, &repo.RepositoryID, + &repo.WorktreeID, &repo.WorktreePath, &repo.WorktreeBranch, + &repo.Position, &repo.ErrorMessage, &repo.CreatedAt, &repo.UpdatedAt, + ); err != nil { + return nil, err + } + out = append(out, repo) + } + return out, rows.Err() +} + +// UpdateTaskEnvironmentRepo updates an existing per-repo row. +func (r *Repository) UpdateTaskEnvironmentRepo(ctx context.Context, repo *models.TaskEnvironmentRepo) error { + repo.UpdatedAt = time.Now().UTC() + + result, err := r.db.ExecContext(ctx, r.db.Rebind(` + UPDATE task_environment_repos SET + worktree_id = ?, worktree_path = ?, worktree_branch = ?, + position = ?, error_message = ?, updated_at = ? + WHERE id = ? + `), + repo.WorktreeID, repo.WorktreePath, repo.WorktreeBranch, + repo.Position, repo.ErrorMessage, repo.UpdatedAt, + repo.ID, + ) + if err != nil { + return err + } + rows, _ := result.RowsAffected() + if rows == 0 { + return fmt.Errorf("task environment repo not found: %s", repo.ID) + } + return nil +} + +// DeleteTaskEnvironmentRepo removes a single per-repo row. +func (r *Repository) DeleteTaskEnvironmentRepo(ctx context.Context, id string) error { + result, err := r.db.ExecContext(ctx, r.db.Rebind(` + DELETE FROM task_environment_repos WHERE id = ? + `), id) + if err != nil { + return err + } + rows, _ := result.RowsAffected() + if rows == 0 { + return fmt.Errorf("task environment repo not found: %s", id) + } + return nil +} + +// DeleteTaskEnvironmentReposByEnv removes all per-repo rows for an environment. +func (r *Repository) DeleteTaskEnvironmentReposByEnv(ctx context.Context, envID string) error { + _, err := r.db.ExecContext(ctx, r.db.Rebind(` + DELETE FROM task_environment_repos WHERE task_environment_id = ? + `), envID) + return err +} diff --git a/apps/backend/internal/task/repository/task_environment_repo_test.go b/apps/backend/internal/task/repository/task_environment_repo_test.go new file mode 100644 index 0000000000..06f5b43310 --- /dev/null +++ b/apps/backend/internal/task/repository/task_environment_repo_test.go @@ -0,0 +1,289 @@ +package repository + +import ( + "context" + "path/filepath" + "testing" + + "github.com/jmoiron/sqlx" + + "github.com/kandev/kandev/internal/db" + "github.com/kandev/kandev/internal/task/models" + "github.com/kandev/kandev/internal/task/repository/sqlite" + "github.com/kandev/kandev/internal/worktree" +) + +// Phase 0 tests for the multi-repo TaskEnvironment / TaskEnvironmentRepo schema. + +func newEnv(t *testing.T, repo *sqlite.Repository, taskID, envID string) *models.TaskEnvironment { + t.Helper() + env := &models.TaskEnvironment{ + ID: envID, + TaskID: taskID, + ExecutorType: "local_pc", + Status: models.TaskEnvironmentStatusReady, + TaskDirName: "fix-bug_ab12", + } + if err := repo.CreateTaskEnvironment(context.Background(), env); err != nil { + t.Fatalf("create env: %v", err) + } + return env +} + +func newTaskWithRepo(t *testing.T, repo *sqlite.Repository, taskID string) { + t.Helper() + ctx := context.Background() + wf := &models.Workflow{ID: "wf-env", Name: "Env Workflow"} + _ = repo.CreateWorkflow(ctx, wf) + task := &models.Task{ID: taskID, WorkflowID: wf.ID, Title: "Env Task"} + if err := repo.CreateTask(ctx, task); err != nil { + t.Fatalf("create task: %v", err) + } +} + +func TestTaskEnvironment_TaskDirNameRoundTrip(t *testing.T) { + repo, cleanup := createTestSQLiteRepo(t) + defer cleanup() + + newTaskWithRepo(t, repo, "task-env-1") + env := newEnv(t, repo, "task-env-1", "env-1") + + got, err := repo.GetTaskEnvironment(context.Background(), env.ID) + if err != nil { + t.Fatalf("get env: %v", err) + } + if got.TaskDirName != "fix-bug_ab12" { + t.Errorf("expected task_dir_name persisted; got %q", got.TaskDirName) + } + if len(got.Repos) != 0 { + t.Errorf("expected 0 repos, got %d", len(got.Repos)) + } +} + +func TestTaskEnvironmentRepo_CRUD(t *testing.T) { + repo, cleanup := createTestSQLiteRepo(t) + defer cleanup() + ctx := context.Background() + + newTaskWithRepo(t, repo, "task-env-2") + env := newEnv(t, repo, "task-env-2", "env-2") + + er := &models.TaskEnvironmentRepo{ + TaskEnvironmentID: env.ID, + RepositoryID: "repo-frontend", + WorktreeID: "wt-1", + WorktreePath: "/tmp/tasks/x/frontend", + WorktreeBranch: "feature/x", + Position: 0, + } + if err := repo.CreateTaskEnvironmentRepo(ctx, er); err != nil { + t.Fatalf("create env repo: %v", err) + } + if er.ID == "" { + t.Fatal("expected ID to be assigned") + } + + list, err := repo.ListTaskEnvironmentRepos(ctx, env.ID) + if err != nil { + t.Fatalf("list: %v", err) + } + if len(list) != 1 || list[0].RepositoryID != "repo-frontend" { + t.Fatalf("unexpected list: %+v", list) + } + + er.WorktreeBranch = "feature/x-renamed" + er.ErrorMessage = "fetch failed" + if err := repo.UpdateTaskEnvironmentRepo(ctx, er); err != nil { + t.Fatalf("update: %v", err) + } + list, _ = repo.ListTaskEnvironmentRepos(ctx, env.ID) + if list[0].WorktreeBranch != "feature/x-renamed" || list[0].ErrorMessage != "fetch failed" { + t.Errorf("update did not persist: %+v", list[0]) + } + + if err := repo.DeleteTaskEnvironmentRepo(ctx, er.ID); err != nil { + t.Fatalf("delete: %v", err) + } + list, _ = repo.ListTaskEnvironmentRepos(ctx, env.ID) + if len(list) != 0 { + t.Errorf("expected empty list after delete, got %d", len(list)) + } +} + +func TestTaskEnvironmentRepo_PreloadOnGet(t *testing.T) { + repo, cleanup := createTestSQLiteRepo(t) + defer cleanup() + ctx := context.Background() + + newTaskWithRepo(t, repo, "task-env-3") + env := newEnv(t, repo, "task-env-3", "env-3") + + rows := []*models.TaskEnvironmentRepo{ + {TaskEnvironmentID: env.ID, RepositoryID: "repo-backend", Position: 1, WorktreePath: "/b"}, + {TaskEnvironmentID: env.ID, RepositoryID: "repo-frontend", Position: 0, WorktreePath: "/f"}, + } + for _, r := range rows { + if err := repo.CreateTaskEnvironmentRepo(ctx, r); err != nil { + t.Fatalf("create: %v", err) + } + } + + got, err := repo.GetTaskEnvironment(ctx, env.ID) + if err != nil { + t.Fatalf("get: %v", err) + } + if len(got.Repos) != 2 { + t.Fatalf("expected 2 repos, got %d", len(got.Repos)) + } + if got.Repos[0].RepositoryID != "repo-frontend" { + t.Errorf("expected position-ordered list, frontend (pos=0) first; got %s", got.Repos[0].RepositoryID) + } + if got.RepoFor("repo-backend") == nil { + t.Error("expected RepoFor lookup to find repo-backend") + } + if got.RepoFor("missing") != nil { + t.Error("expected RepoFor to return nil for unknown repo") + } +} + +func TestTaskEnvironmentRepo_CreateWithEmbeddedRepos(t *testing.T) { + repo, cleanup := createTestSQLiteRepo(t) + defer cleanup() + ctx := context.Background() + + newTaskWithRepo(t, repo, "task-env-4") + env := &models.TaskEnvironment{ + ID: "env-4", + TaskID: "task-env-4", + ExecutorType: "local_pc", + Status: models.TaskEnvironmentStatusReady, + Repos: []*models.TaskEnvironmentRepo{ + {RepositoryID: "repo-a", Position: 0, WorktreePath: "/a"}, + {RepositoryID: "repo-b", Position: 1, WorktreePath: "/b"}, + }, + } + if err := repo.CreateTaskEnvironment(ctx, env); err != nil { + t.Fatalf("create env with repos: %v", err) + } + got, err := repo.GetTaskEnvironment(ctx, env.ID) + if err != nil { + t.Fatalf("get: %v", err) + } + if len(got.Repos) != 2 { + t.Fatalf("expected 2 repos persisted, got %d", len(got.Repos)) + } + for _, r := range got.Repos { + if r.TaskEnvironmentID != env.ID { + t.Errorf("expected env id wired; got %q", r.TaskEnvironmentID) + } + } +} + +func TestTaskEnvironmentRepo_CascadeDeleteOnEnvDelete(t *testing.T) { + repo, cleanup := createTestSQLiteRepo(t) + defer cleanup() + ctx := context.Background() + + newTaskWithRepo(t, repo, "task-env-5") + env := newEnv(t, repo, "task-env-5", "env-5") + er := &models.TaskEnvironmentRepo{TaskEnvironmentID: env.ID, RepositoryID: "repo-x"} + if err := repo.CreateTaskEnvironmentRepo(ctx, er); err != nil { + t.Fatalf("create: %v", err) + } + if err := repo.DeleteTaskEnvironment(ctx, env.ID); err != nil { + t.Fatalf("delete env: %v", err) + } + list, err := repo.ListTaskEnvironmentRepos(ctx, env.ID) + if err != nil { + t.Fatalf("list after delete: %v", err) + } + if len(list) != 0 { + t.Errorf("expected cascade delete to remove per-repo rows, got %d", len(list)) + } +} + +func TestTaskEnvironmentRepo_BackfillFromLegacyEnv(t *testing.T) { + // Backfill is run by initSchema. Simulate a "legacy" environment by inserting + // directly into task_environments with repository_id set, then re-opening the + // repository so initSchema re-runs the backfill on the existing data. + tmpDir := t.TempDir() + dbPath := filepath.Join(tmpDir, "backfill.db") + + dbConn, err := db.OpenSQLite(dbPath) + if err != nil { + t.Fatalf("open db: %v", err) + } + sqlxDB := sqlx.NewDb(dbConn, "sqlite3") + repo, err := sqlite.NewWithDB(sqlxDB, sqlxDB) + if err != nil { + t.Fatalf("new repo: %v", err) + } + if _, err := worktree.NewSQLiteStore(sqlxDB, sqlxDB); err != nil { + t.Fatalf("worktree store: %v", err) + } + + ctx := context.Background() + newTaskWithRepo(t, repo, "task-env-6") + + // Insert a legacy-shaped env (no per-repo row exists). + if _, err := sqlxDB.Exec(` + INSERT INTO task_environments ( + id, task_id, repository_id, executor_type, executor_id, executor_profile_id, + agent_execution_id, control_port, status, + worktree_id, worktree_path, worktree_branch, workspace_path, + container_id, sandbox_id, task_dir_name, + created_at, updated_at + ) VALUES ('legacy-env', 'task-env-6', 'repo-legacy', 'local_pc', '', '', + '', 0, 'ready', + 'wt-legacy', '/wt/legacy', 'main', '', + '', '', '', + datetime('now'), datetime('now')) + `); err != nil { + t.Fatalf("insert legacy env: %v", err) + } + + // Verify no per-repo row yet. + list, err := repo.ListTaskEnvironmentRepos(ctx, "legacy-env") + if err != nil { + t.Fatalf("list: %v", err) + } + if len(list) != 0 { + t.Fatalf("expected 0 per-repo rows pre-backfill, got %d", len(list)) + } + + // Re-open the repository to retrigger initSchema → backfill. + if err := repo.Close(); err != nil { + t.Fatalf("close: %v", err) + } + dbConn2, err := db.OpenSQLite(dbPath) + if err != nil { + t.Fatalf("reopen db: %v", err) + } + sqlxDB2 := sqlx.NewDb(dbConn2, "sqlite3") + repo2, err := sqlite.NewWithDB(sqlxDB2, sqlxDB2) + if err != nil { + t.Fatalf("reopen repo: %v", err) + } + defer func() { + _ = repo2.Close() + _ = sqlxDB2.Close() + }() + + list, err = repo2.ListTaskEnvironmentRepos(ctx, "legacy-env") + if err != nil { + t.Fatalf("list after reopen: %v", err) + } + if len(list) != 1 { + t.Fatalf("expected 1 backfilled row, got %d", len(list)) + } + got := list[0] + if got.RepositoryID != "repo-legacy" || got.WorktreeID != "wt-legacy" || got.WorktreeBranch != "main" { + t.Errorf("backfilled row mismatch: %+v", got) + } + + // Idempotency: a second backfill must not duplicate rows. + list, _ = repo2.ListTaskEnvironmentRepos(ctx, "legacy-env") + if len(list) != 1 { + t.Errorf("expected backfill to be idempotent, got %d rows", len(list)) + } +} diff --git a/apps/backend/internal/task/service/repository_discovery.go b/apps/backend/internal/task/service/repository_discovery.go index 313bb37334..83679fd6cf 100644 --- a/apps/backend/internal/task/service/repository_discovery.go +++ b/apps/backend/internal/task/service/repository_discovery.go @@ -136,37 +136,62 @@ func (s *Service) ValidateLocalRepositoryPath(ctx context.Context, path string) }, nil } -func (s *Service) ListRepositoryBranches(ctx context.Context, repoID string) ([]Branch, error) { - repo, err := s.repoEntities.GetRepository(ctx, repoID) - if err != nil { - return nil, err - } - if repo.LocalPath == "" { - return nil, fmt.Errorf("repository local path is empty") - } - absPath, err := filepath.Abs(repo.LocalPath) - if err != nil { - return nil, fmt.Errorf("invalid repository path: %w", err) - } - if !isPathAllowed(absPath, s.discoveryRoots()) { - return nil, ErrPathNotAllowed - } - info, err := os.Stat(absPath) - if err != nil { - return nil, err - } - if !info.IsDir() { - return nil, fmt.Errorf("repository path is not a directory") - } - return listGitBranches(absPath) +// BranchListResult bundles a branch list with the currently-checked-out +// branch so the unified branches endpoint can answer both in one call. +type BranchListResult struct { + Branches []Branch + CurrentBranch string } -func (s *Service) ListLocalRepositoryBranches(ctx context.Context, path string) ([]Branch, error) { - absPath, err := s.resolveAllowedLocalPath(path) +// ListBranches lists git branches for either an imported workspace repo +// (by id, path resolved from the DB row) or an on-machine folder (by path +// directly). Exactly one of `repoID` or `path` should be set; the caller +// (the HTTP handler) validates that. +func (s *Service) ListBranches(ctx context.Context, repoID, path string) ([]Branch, error) { + resolved, err := s.resolveBranchListingPath(ctx, repoID, path) if err != nil { return nil, err } - return listGitBranches(absPath) + return listGitBranches(resolved) +} + +// ListBranchesWithCurrent is ListBranches plus the current-branch readout. +// One method so the handler resolves the path once instead of twice. +func (s *Service) ListBranchesWithCurrent(ctx context.Context, repoID, path string) (BranchListResult, error) { + resolved, err := s.resolveBranchListingPath(ctx, repoID, path) + if err != nil { + return BranchListResult{}, err + } + branches, err := listGitBranches(resolved) + if err != nil { + return BranchListResult{}, err + } + // Current branch is best-effort metadata; an empty string is fine if + // HEAD is detached or unreadable. + return BranchListResult{ + Branches: branches, + CurrentBranch: readGitCurrentBranch(resolved, s.discoveryRoots()), + }, nil +} + +// resolveBranchListingPath turns the request inputs into a validated +// absolute path to list branches in. Both inputs go through the same +// allowed-roots / dir / symlink validation via resolveAllowedLocalPath. +func (s *Service) resolveBranchListingPath(ctx context.Context, repoID, path string) (string, error) { + switch { + case repoID != "": + repo, err := s.repoEntities.GetRepository(ctx, repoID) + if err != nil { + return "", err + } + if repo.LocalPath == "" { + return "", fmt.Errorf("repository local path is empty") + } + path = repo.LocalPath + case path == "": + return "", fmt.Errorf("repository_id or path is required") + } + return s.resolveAllowedLocalPath(path) } // LocalRepositoryCurrentBranch returns the currently checked-out branch for a diff --git a/apps/backend/internal/task/service/service_events.go b/apps/backend/internal/task/service/service_events.go index 7eb95eb6c5..768741a0b0 100644 --- a/apps/backend/internal/task/service/service_events.go +++ b/apps/backend/internal/task/service/service_events.go @@ -50,11 +50,14 @@ func (s *Service) publishTaskEvent(ctx context.Context, eventType string, task * data["archived_at"] = task.ArchivedAt.Format(time.RFC3339) } // Orchestrator-originated events fetch the task via the raw repo.GetTask, - // which does not populate Repositories. Load the primary on demand so the - // payload always carries repository_id — matching the HTTP DTO and - // preventing the frontend from losing repositoryId on WS updates. - if repoID := primaryRepositoryID(ctx, s, task); repoID != "" { - data["repository_id"] = repoID + // which does not populate Repositories. Load on demand so the payload + // carries the full per-task repository list — matching the HTTP DTO and + // preventing the frontend from collapsing multi-repo tasks down to the + // primary repo on WS updates. + repos := taskRepositoriesForEvent(ctx, s, task) + if len(repos) > 0 { + data["repository_id"] = repos[0].RepositoryID + data["repositories"] = serializeTaskRepositories(repos) } if task.Metadata != nil { data["metadata"] = task.Metadata @@ -116,19 +119,37 @@ func (s *Service) addTaskSessionEventFields(ctx context.Context, taskID string, } } -// primaryRepositoryID returns the primary repository_id for the task. Prefers -// the already-loaded Task.Repositories slice; falls back to a lookup so -// publishers that pass a task without eagerly loaded repositories (e.g. the -// orchestrator's raw repo.GetTask) still emit repository_id. -func primaryRepositoryID(ctx context.Context, s *Service, task *models.Task) string { +// taskRepositoriesForEvent returns the task's full repository list, ordered by +// position. Prefers Task.Repositories when already loaded; falls back to a +// lookup so publishers that pass a task without eagerly loaded repositories +// (e.g. the orchestrator's raw repo.GetTask) still emit per-repo data. +func taskRepositoriesForEvent(ctx context.Context, s *Service, task *models.Task) []*models.TaskRepository { if len(task.Repositories) > 0 { - return task.Repositories[0].RepositoryID + return task.Repositories } - repo, err := s.taskRepos.GetPrimaryTaskRepository(ctx, task.ID) - if err != nil || repo == nil { - return "" + repos, err := s.taskRepos.ListTaskRepositories(ctx, task.ID) + if err != nil { + return nil } - return repo.RepositoryID + return repos +} + +// serializeTaskRepositories returns the WS-shaped repositories array. Mirrors +// the HTTP DTO's TaskRepositoryDTO so the frontend can use the same parser +// across SSR snapshot and WS update paths. +func serializeTaskRepositories(repos []*models.TaskRepository) []map[string]interface{} { + out := make([]map[string]interface{}, 0, len(repos)) + for _, r := range repos { + out = append(out, map[string]interface{}{ + "id": r.ID, + "task_id": r.TaskID, + "repository_id": r.RepositoryID, + "base_branch": r.BaseBranch, + "checkout_branch": r.CheckoutBranch, + "position": r.Position, + }) + } + return out } // publishTaskMovedEvent publishes a task.moved event so the orchestrator can process diff --git a/apps/backend/internal/task/service/service_tasks.go b/apps/backend/internal/task/service/service_tasks.go index 549b040273..66228cea40 100644 --- a/apps/backend/internal/task/service/service_tasks.go +++ b/apps/backend/internal/task/service/service_tasks.go @@ -120,6 +120,7 @@ func (s *Service) createTaskRepositories(ctx context.Context, taskID, workspaceI } } + seen := make(map[string]bool, len(repositories)) for i, repoInput := range repositories { repositoryID, baseBranch, err := s.resolveRepoInput(ctx, workspaceID, repoInput, repoByPath) if err != nil { @@ -128,6 +129,14 @@ func (s *Service) createTaskRepositories(ctx context.Context, taskID, workspaceI if repositoryID == "" { return fmt.Errorf("repository_id is required") } + // Multi-repo validation: each repository may appear at most once per task. + // Without this guard the unique (task_id, repository_id) constraint on + // task_repositories would surface as an opaque DB error mid-loop, leaving + // some rows already inserted. + if seen[repositoryID] { + return fmt.Errorf("repository %q listed more than once for the task", repositoryID) + } + seen[repositoryID] = true taskRepo := &models.TaskRepository{ TaskID: taskID, RepositoryID: repositoryID, diff --git a/apps/backend/internal/task/service/service_test.go b/apps/backend/internal/task/service/service_test.go index 36bb30297e..7dd444e1dd 100644 --- a/apps/backend/internal/task/service/service_test.go +++ b/apps/backend/internal/task/service/service_test.go @@ -168,6 +168,58 @@ func TestService_CreateTask(t *testing.T) { } } +func TestService_CreateTask_RejectsDuplicateRepositories(t *testing.T) { + svc, _, repo := createTestService(t) + ctx := context.Background() + + _ = repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-1", Name: "Workspace"}) + _ = repo.CreateWorkflow(ctx, &models.Workflow{ID: "wf-1", WorkspaceID: "ws-1", Name: "WF"}) + _ = repo.CreateRepository(ctx, &models.Repository{ID: "repo-dup", WorkspaceID: "ws-1", Name: "Dup"}) + + req := &CreateTaskRequest{ + WorkspaceID: "ws-1", + WorkflowID: "wf-1", + WorkflowStepID: "step-1", + Title: "dup repos", + Repositories: []TaskRepositoryInput{ + {RepositoryID: "repo-dup", BaseBranch: "main"}, + {RepositoryID: "repo-dup", BaseBranch: "main"}, + }, + } + _, err := svc.CreateTask(ctx, req) + if err == nil { + t.Fatal("expected duplicate-repository error, got nil") + } +} + +func TestService_CreateTask_AcceptsMultipleDistinctRepositories(t *testing.T) { + svc, _, repo := createTestService(t) + ctx := context.Background() + + _ = repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-1", Name: "Workspace"}) + _ = repo.CreateWorkflow(ctx, &models.Workflow{ID: "wf-1", WorkspaceID: "ws-1", Name: "WF"}) + _ = repo.CreateRepository(ctx, &models.Repository{ID: "repo-front", WorkspaceID: "ws-1", Name: "front"}) + _ = repo.CreateRepository(ctx, &models.Repository{ID: "repo-back", WorkspaceID: "ws-1", Name: "back"}) + + req := &CreateTaskRequest{ + WorkspaceID: "ws-1", + WorkflowID: "wf-1", + WorkflowStepID: "step-1", + Title: "multi", + Repositories: []TaskRepositoryInput{ + {RepositoryID: "repo-front", BaseBranch: "main"}, + {RepositoryID: "repo-back", BaseBranch: "main"}, + }, + } + task, err := svc.CreateTask(ctx, req) + if err != nil { + t.Fatalf("CreateTask: %v", err) + } + if len(task.Repositories) != 2 { + t.Errorf("expected 2 task repositories, got %d", len(task.Repositories)) + } +} + func TestService_CreateRepository_DefaultWorktreeBranchPrefix(t *testing.T) { svc, _, repo := createTestService(t) ctx := context.Background() diff --git a/apps/backend/internal/task/service/service_turns.go b/apps/backend/internal/task/service/service_turns.go index 5cca5e15c7..07e4d595e7 100644 --- a/apps/backend/internal/task/service/service_turns.go +++ b/apps/backend/internal/task/service/service_turns.go @@ -5,6 +5,7 @@ import ( "database/sql" "errors" "fmt" + "path/filepath" "sort" "time" @@ -215,9 +216,16 @@ func (s *Service) GetWorkspaceInfoForSession(ctx context.Context, taskID, sessio taskID = session.TaskID } - // Get workspace path from the session's worktree + // Get workspace path from the session's worktree(s). + // Multi-repo: every per-repo worktree sits as a sibling under the task root + // (~/.kandev/tasks/{taskDirName}/{repoName}/), so the workspace path agentctl + // needs is the parent of any one of them. Picking the first repo's path here + // would point agentctl at a single repo subdir and disable the per-repo + // tracker fan-out that scanRepositorySubdirs relies on. var workspacePath string - if len(session.Worktrees) > 0 { + if len(session.Worktrees) > 1 { + workspacePath = filepath.Dir(session.Worktrees[0].WorktreePath) + } else if len(session.Worktrees) == 1 { workspacePath = session.Worktrees[0].WorktreePath } diff --git a/apps/backend/internal/task/service/service_turns_test.go b/apps/backend/internal/task/service/service_turns_test.go index 05d1a4e150..f91cdb0e70 100644 --- a/apps/backend/internal/task/service/service_turns_test.go +++ b/apps/backend/internal/task/service/service_turns_test.go @@ -2,6 +2,7 @@ package service import ( "context" + "fmt" "testing" "time" @@ -266,3 +267,52 @@ func TestGetWorkspaceInfoForEnvironment(t *testing.T) { t.Errorf("TaskEnvironmentID = %q, want env-123", info.TaskEnvironmentID) } } + +// Multi-repo: the workspace path agentctl boots with must be the task root +// (parent of every per-repo subdir) so its scanRepositorySubdirs detects all +// repos and starts a per-repo tracker for each. Returning a single repo's +// path would collapse fan-out into the legacy single-tracker mode and +// suppress the per-repo events the Changes panel needs to render headers. +func TestGetWorkspaceInfoForSession_MultiRepoReturnsTaskRoot(t *testing.T) { + svc, _, repo := createTestService(t) + ctx := context.Background() + + setupTestTask(t, repo) + now := time.Now().UTC() + + session := &models.TaskSession{ + ID: "session-multi", + TaskID: "task-123", + State: models.TaskSessionStateCompleted, + StartedAt: now, + UpdatedAt: now, + } + if err := repo.CreateTaskSession(ctx, session); err != nil { + t.Fatalf("create session: %v", err) + } + for i, path := range []string{ + "/tmp/tasks/do-nothing_mvo/kandev", + "/tmp/tasks/do-nothing_mvo/thm", + } { + if err := repo.CreateTaskSessionWorktree(ctx, &models.TaskSessionWorktree{ + ID: fmt.Sprintf("wt%d", i), + SessionID: session.ID, + WorktreeID: fmt.Sprintf("wid%d", i), + RepositoryID: fmt.Sprintf("repo%d", i), + Position: i, + WorktreePath: path, + CreatedAt: now, + }); err != nil { + t.Fatalf("create worktree %d: %v", i, err) + } + } + + info, err := svc.GetWorkspaceInfoForSession(ctx, "task-123", session.ID) + if err != nil { + t.Fatalf("GetWorkspaceInfoForSession: %v", err) + } + if info.WorkspacePath != "/tmp/tasks/do-nothing_mvo" { + t.Errorf("expected WorkspacePath '/tmp/tasks/do-nothing_mvo' (task root), got %q", + info.WorkspacePath) + } +} diff --git a/apps/backend/internal/worktree/manager.go b/apps/backend/internal/worktree/manager.go index fe11ad4b6d..b31778eb40 100644 --- a/apps/backend/internal/worktree/manager.go +++ b/apps/backend/internal/worktree/manager.go @@ -32,11 +32,15 @@ type repoLockEntry struct { // Manager handles Git worktree operations for concurrent agent execution. type Manager struct { - config Config - logger *logger.Logger - store Store - worktrees map[string]*Worktree // sessionID -> worktree (in-memory cache) - mu sync.RWMutex // Protects worktrees map + config Config + logger *logger.Logger + store Store + // worktrees is the in-memory cache keyed by cacheKey(sessionID, repositoryID). + // For legacy single-repo writes the repositoryID may be empty, in which + // case the cache key collapses to "{sessionID}|" — still distinct from + // any per-repo entry the same session might gain later. + worktrees map[string]*Worktree + mu sync.RWMutex // Protects worktrees map repoLocks map[string]*repoLockEntry repoLockMu sync.Mutex @@ -63,7 +67,10 @@ type Store interface { CreateWorktree(ctx context.Context, wt *Worktree) error // GetWorktreeByID retrieves a worktree by its unique ID. GetWorktreeByID(ctx context.Context, id string) (*Worktree, error) - // GetWorktreeBySessionID retrieves the worktree by session ID. + // GetWorktreeBySessionID retrieves a single active worktree by session ID. + // For multi-repo sessions, returns the first one found (typically the + // primary/first-created). Use GetWorktreesBySessionID when the caller + // needs all of them. GetWorktreeBySessionID(ctx context.Context, sessionID string) (*Worktree, error) // GetWorktreesByTaskID retrieves all worktrees for a task (used for cleanup on task deletion). GetWorktreesByTaskID(ctx context.Context, taskID string) ([]*Worktree, error) @@ -77,6 +84,18 @@ type Store interface { ListActiveWorktrees(ctx context.Context) ([]*Worktree, error) } +// MultiRepoStore is an optional capability some stores implement to support +// multi-repo task sessions (one worktree per repository per session). The +// Manager checks at runtime whether its Store satisfies this interface and +// uses the multi-repo lookups when available. +type MultiRepoStore interface { + // GetWorktreesBySessionID returns all active worktrees for the session. + GetWorktreesBySessionID(ctx context.Context, sessionID string) ([]*Worktree, error) + // GetWorktreeBySessionAndRepository returns the active worktree for the + // given (session, repository) pair, or nil if none exists. + GetWorktreeBySessionAndRepository(ctx context.Context, sessionID, repositoryID string) (*Worktree, error) +} + // NewManager creates a new worktree manager. func NewManager(cfg Config, store Store, log *logger.Logger) (*Manager, error) { if err := cfg.Validate(); err != nil { @@ -181,14 +200,18 @@ func (m *Manager) Create(ctx context.Context, req CreateRequest) (*Worktree, err return nil, err } - // First, check if a worktree already exists for this session + // First, check if a worktree already exists for this (session, repository) pair. + // Multi-repo sessions can host multiple worktrees concurrently, so we must + // scope the lookup by RepositoryID rather than session alone — otherwise + // the second repo's Create would return the first repo's worktree. if req.SessionID != "" { - existing, err := m.GetBySessionID(ctx, req.SessionID) + existing, err := m.GetBySessionAndRepo(ctx, req.SessionID, req.RepositoryID) if err == nil && existing != nil { if m.IsValid(existing.Path) { - m.logger.Debug("reusing existing worktree by session ID", + m.logger.Debug("reusing existing worktree by session+repo", zap.String("worktree_id", existing.ID), zap.String("session_id", req.SessionID), + zap.String("repository_id", req.RepositoryID), zap.String("task_id", req.TaskID), zap.String("path", existing.Path)) return existing, nil @@ -197,6 +220,7 @@ func (m *Manager) Create(ctx context.Context, req CreateRequest) (*Worktree, err m.logger.Warn("worktree directory invalid, recreating", zap.String("worktree_id", existing.ID), zap.String("session_id", req.SessionID), + zap.String("repository_id", req.RepositoryID), zap.String("task_id", req.TaskID)) return m.recreate(ctx, existing, req) } @@ -732,10 +756,11 @@ func (m *Manager) persistAndCacheWorktree(ctx context.Context, wt *Worktree, req } } - // Update cache (keyed by sessionID) + // Update cache keyed by (sessionID, repositoryID) so multi-repo sessions + // can hold multiple entries. if req.SessionID != "" { m.mu.Lock() - m.worktrees[req.SessionID] = wt + m.worktrees[cacheKey(req.SessionID, req.RepositoryID)] = wt m.mu.Unlock() } return nil @@ -760,11 +785,33 @@ func (m *Manager) persistWorktree(ctx context.Context, wt *Worktree, req CreateR return nil } -// GetBySessionID returns the worktree for a session, if it exists. +// cacheKey computes the cache key for a (sessionID, repositoryID) pair. +// Used by all read/write paths against m.worktrees so multi-repo sessions +// can hold multiple worktrees concurrently. +func cacheKey(sessionID, repositoryID string) string { + return sessionID + "|" + repositoryID +} + +// firstCacheEntryForSession scans the cache for any entry belonging to +// sessionID. Returns the first match or nil. Caller must hold m.mu. +func (m *Manager) firstCacheEntryForSession(sessionID string) *Worktree { + prefix := sessionID + "|" + for k, wt := range m.worktrees { + if strings.HasPrefix(k, prefix) { + return wt + } + } + return nil +} + +// GetBySessionID returns one worktree for the session. For multi-repo sessions +// it returns whichever worktree happens to come first; callers that need a +// specific repo's worktree should use GetBySessionAndRepo, and callers that +// need them all should use GetAllBySessionID. func (m *Manager) GetBySessionID(ctx context.Context, sessionID string) (*Worktree, error) { - // Check cache first (keyed by sessionID) + // Check cache first m.mu.RLock() - if wt, ok := m.worktrees[sessionID]; ok { + if wt := m.firstCacheEntryForSession(sessionID); wt != nil { m.mu.RUnlock() return wt, nil } @@ -777,9 +824,10 @@ func (m *Manager) GetBySessionID(ctx context.Context, sessionID string) (*Worktr return nil, err } if wt != nil { - // Update cache + // Update cache under the wt's repository key (or empty for legacy + // records that never had one). m.mu.Lock() - m.worktrees[sessionID] = wt + m.worktrees[cacheKey(sessionID, wt.RepositoryID)] = wt m.mu.Unlock() return wt, nil } @@ -788,6 +836,88 @@ func (m *Manager) GetBySessionID(ctx context.Context, sessionID string) (*Worktr return nil, ErrWorktreeNotFound } +// GetAllBySessionID returns all active worktrees for a session. Empty slice +// (not error) if the session has none. +func (m *Manager) GetAllBySessionID(ctx context.Context, sessionID string) ([]*Worktree, error) { + if m.store == nil { + // Cache-only path: scan our in-memory map. + m.mu.RLock() + defer m.mu.RUnlock() + prefix := sessionID + "|" + var out []*Worktree + for k, wt := range m.worktrees { + if strings.HasPrefix(k, prefix) { + out = append(out, wt) + } + } + return out, nil + } + if multi, ok := m.store.(MultiRepoStore); ok { + wts, err := multi.GetWorktreesBySessionID(ctx, sessionID) + if err != nil { + return nil, err + } + // Refresh cache while we're at it. + m.mu.Lock() + for _, wt := range wts { + m.worktrees[cacheKey(sessionID, wt.RepositoryID)] = wt + } + m.mu.Unlock() + return wts, nil + } + // Single-repo store fallback: at most one worktree per session. + wt, err := m.store.GetWorktreeBySessionID(ctx, sessionID) + if err != nil { + return nil, err + } + if wt == nil { + return nil, nil + } + return []*Worktree{wt}, nil +} + +// GetBySessionAndRepo returns the active worktree for the (session, repo) +// pair, or ErrWorktreeNotFound if none exists. Falls back to GetBySessionID +// when the store does not implement MultiRepoStore (legacy single-repo). +func (m *Manager) GetBySessionAndRepo(ctx context.Context, sessionID, repositoryID string) (*Worktree, error) { + if sessionID == "" { + return nil, ErrWorktreeNotFound + } + // Check cache first. + m.mu.RLock() + if wt, ok := m.worktrees[cacheKey(sessionID, repositoryID)]; ok { + m.mu.RUnlock() + return wt, nil + } + m.mu.RUnlock() + + if m.store == nil { + return nil, ErrWorktreeNotFound + } + if multi, ok := m.store.(MultiRepoStore); ok { + wt, err := multi.GetWorktreeBySessionAndRepository(ctx, sessionID, repositoryID) + if err != nil { + return nil, err + } + if wt == nil { + return nil, ErrWorktreeNotFound + } + m.mu.Lock() + m.worktrees[cacheKey(sessionID, repositoryID)] = wt + m.mu.Unlock() + return wt, nil + } + // Legacy single-repo store: best we can do is the session lookup. + wt, err := m.store.GetWorktreeBySessionID(ctx, sessionID) + if err != nil { + return nil, err + } + if wt == nil || (repositoryID != "" && wt.RepositoryID != repositoryID) { + return nil, ErrWorktreeNotFound + } + return wt, nil +} + // GetByID returns a worktree by its unique ID. func (m *Manager) GetByID(ctx context.Context, worktreeID string) (*Worktree, error) { if m.store == nil { @@ -900,10 +1030,11 @@ func (m *Manager) removeWorktree(ctx context.Context, wt *Worktree, removeBranch } } - // Update cache + // Update cache: delete the (session, repo) entry. Removing a worktree only + // affects its own repo; siblings on other repos must remain cached. m.mu.Lock() if wt.SessionID != "" { - delete(m.worktrees, wt.SessionID) + delete(m.worktrees, cacheKey(wt.SessionID, wt.RepositoryID)) } m.mu.Unlock() @@ -962,7 +1093,7 @@ func (m *Manager) runWorktreeSetupScript(ctx context.Context, wt *Worktree, repo func (m *Manager) cleanupWorktreeOnSetupFailure(ctx context.Context, wt *Worktree, repositoryPath string) { if wt.SessionID != "" { m.mu.Lock() - delete(m.worktrees, wt.SessionID) + delete(m.worktrees, cacheKey(wt.SessionID, wt.RepositoryID)) m.mu.Unlock() } if cleanupErr := m.removeWorktreeDir(ctx, wt.Path, repositoryPath); cleanupErr != nil { @@ -1042,7 +1173,7 @@ func (m *Manager) CleanupWorktrees(ctx context.Context, worktrees []*Worktree) e continue } if wt.SessionID != "" { - delete(m.worktrees, wt.SessionID) + delete(m.worktrees, cacheKey(wt.SessionID, wt.RepositoryID)) } } m.mu.Unlock() @@ -1387,10 +1518,10 @@ func (m *Manager) recreate(ctx context.Context, existing *Worktree, req CreateRe } } - // Update cache (keyed by sessionID) + // Update cache keyed by (sessionID, repositoryID). if req.SessionID != "" { m.mu.Lock() - m.worktrees[req.SessionID] = existing + m.worktrees[cacheKey(req.SessionID, req.RepositoryID)] = existing m.mu.Unlock() } diff --git a/apps/backend/internal/worktree/manager_multi_repo_test.go b/apps/backend/internal/worktree/manager_multi_repo_test.go new file mode 100644 index 0000000000..abd139091e --- /dev/null +++ b/apps/backend/internal/worktree/manager_multi_repo_test.go @@ -0,0 +1,115 @@ +package worktree + +import ( + "context" + "testing" + "time" +) + +// helpers --------------------------------------------------------------------- + +func newMultiRepoTestManager(t *testing.T) (*Manager, *mockStore) { + t.Helper() + cfg := newTestConfig(t) + store := newMockStore() + mgr, err := NewManager(cfg, store, newTestLogger()) + if err != nil { + t.Fatalf("NewManager: %v", err) + } + return mgr, store +} + +func seedWorktree(store *mockStore, sessionID, repoID, id string) *Worktree { + wt := &Worktree{ + ID: id, + SessionID: sessionID, + RepositoryID: repoID, + TaskID: "task-x", + Path: "/tmp/wt/" + id, + Branch: "feat/" + id, + Status: StatusActive, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + store.worktrees[id] = wt + return wt +} + +// tests ----------------------------------------------------------------------- + +func TestManager_GetAllBySessionID_ReturnsAllRepos(t *testing.T) { + mgr, store := newMultiRepoTestManager(t) + seedWorktree(store, "sess-1", "repo-front", "wt-1") + seedWorktree(store, "sess-1", "repo-back", "wt-2") + seedWorktree(store, "sess-2", "repo-front", "wt-3") // unrelated session + + got, err := mgr.GetAllBySessionID(context.Background(), "sess-1") + if err != nil { + t.Fatalf("GetAllBySessionID: %v", err) + } + if len(got) != 2 { + t.Fatalf("expected 2 worktrees for sess-1, got %d", len(got)) + } + ids := map[string]bool{got[0].ID: true, got[1].ID: true} + if !ids["wt-1"] || !ids["wt-2"] { + t.Errorf("unexpected ids returned: %v", ids) + } +} + +func TestManager_GetBySessionAndRepo_PicksTheRightOne(t *testing.T) { + mgr, store := newMultiRepoTestManager(t) + seedWorktree(store, "sess-1", "repo-front", "wt-1") + seedWorktree(store, "sess-1", "repo-back", "wt-2") + + got, err := mgr.GetBySessionAndRepo(context.Background(), "sess-1", "repo-back") + if err != nil { + t.Fatalf("GetBySessionAndRepo: %v", err) + } + if got.ID != "wt-2" { + t.Errorf("expected wt-2 for repo-back, got %s", got.ID) + } +} + +func TestManager_GetBySessionAndRepo_NotFoundForUnknownRepo(t *testing.T) { + mgr, store := newMultiRepoTestManager(t) + seedWorktree(store, "sess-1", "repo-front", "wt-1") + + _, err := mgr.GetBySessionAndRepo(context.Background(), "sess-1", "nope") + if err == nil { + t.Fatal("expected ErrWorktreeNotFound for unknown repo") + } +} + +func TestManager_GetBySessionID_ReturnsAnyMatch_SingleRepoCompat(t *testing.T) { + mgr, store := newMultiRepoTestManager(t) + seedWorktree(store, "sess-1", "repo-only", "wt-1") + + got, err := mgr.GetBySessionID(context.Background(), "sess-1") + if err != nil { + t.Fatalf("GetBySessionID: %v", err) + } + if got.ID != "wt-1" { + t.Errorf("expected wt-1, got %s", got.ID) + } +} + +func TestManager_CacheKey_SeparatesReposPerSession(t *testing.T) { + // Two cache entries for the same session must not collide. We simulate by + // directly writing to the cache the same way persistAndCacheWorktree does. + mgr, _ := newMultiRepoTestManager(t) + wtA := &Worktree{ID: "wt-A", SessionID: "s", RepositoryID: "rA", Path: "/a", Status: StatusActive} + wtB := &Worktree{ID: "wt-B", SessionID: "s", RepositoryID: "rB", Path: "/b", Status: StatusActive} + mgr.mu.Lock() + mgr.worktrees[cacheKey("s", "rA")] = wtA + mgr.worktrees[cacheKey("s", "rB")] = wtB + mgr.mu.Unlock() + + gotA, err := mgr.GetBySessionAndRepo(context.Background(), "s", "rA") + if err != nil || gotA.ID != "wt-A" { + t.Errorf("expected wt-A from cache; got %v err=%v", gotA, err) + } + gotB, err := mgr.GetBySessionAndRepo(context.Background(), "s", "rB") + if err != nil || gotB.ID != "wt-B" { + t.Errorf("expected wt-B from cache; got %v err=%v", gotB, err) + } +} diff --git a/apps/backend/internal/worktree/manager_test.go b/apps/backend/internal/worktree/manager_test.go index ad9c3d343b..21a8aef010 100644 --- a/apps/backend/internal/worktree/manager_test.go +++ b/apps/backend/internal/worktree/manager_test.go @@ -103,6 +103,27 @@ func (s *mockStore) ListActiveWorktrees(ctx context.Context) ([]*Worktree, error return result, nil } +// GetWorktreesBySessionID — MultiRepoStore. +func (s *mockStore) GetWorktreesBySessionID(_ context.Context, sessionID string) ([]*Worktree, error) { + var out []*Worktree + for _, wt := range s.worktrees { + if wt.SessionID == sessionID && wt.Status == StatusActive { + out = append(out, wt) + } + } + return out, nil +} + +// GetWorktreeBySessionAndRepository — MultiRepoStore. +func (s *mockStore) GetWorktreeBySessionAndRepository(_ context.Context, sessionID, repoID string) (*Worktree, error) { + for _, wt := range s.worktrees { + if wt.SessionID == sessionID && wt.RepositoryID == repoID && wt.Status == StatusActive { + return wt, nil + } + } + return nil, nil +} + func TestNewManager(t *testing.T) { cfg := newTestConfig(t) log := newTestLogger() diff --git a/apps/backend/internal/worktree/store.go b/apps/backend/internal/worktree/store.go index 6506ab8ad5..269692aec0 100644 --- a/apps/backend/internal/worktree/store.go +++ b/apps/backend/internal/worktree/store.go @@ -441,5 +441,68 @@ func (s *SQLiteStore) scanWorktrees(rows *sql.Rows) ([]*Worktree, error) { return result, rows.Err() } -// Ensure SQLiteStore implements Store interface. -var _ Store = (*SQLiteStore)(nil) +// GetWorktreesBySessionID returns all active worktrees for the session. +// Implements MultiRepoStore. +func (s *SQLiteStore) GetWorktreesBySessionID(ctx context.Context, sessionID string) ([]*Worktree, error) { + rows, err := s.ro.QueryContext(ctx, s.ro.Rebind(` + SELECT + tsw.worktree_id, + tsw.session_id, + s.task_id, + tsw.repository_id, + r.local_path, + tsw.worktree_path, + tsw.worktree_branch, + s.base_branch, + tsw.status, + tsw.created_at, + tsw.updated_at, + tsw.merged_at, + tsw.deleted_at + FROM task_session_worktrees tsw + INNER JOIN task_sessions s ON tsw.session_id = s.id + LEFT JOIN repositories r ON tsw.repository_id = r.id + WHERE tsw.session_id = ? AND tsw.status = ? + ORDER BY tsw.position ASC, tsw.created_at ASC + `), sessionID, StatusActive) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() + + return s.scanWorktrees(rows) +} + +// GetWorktreeBySessionAndRepository returns the active worktree for the +// given (session, repository) pair, or nil if none exists. +// Implements MultiRepoStore. +func (s *SQLiteStore) GetWorktreeBySessionAndRepository(ctx context.Context, sessionID, repositoryID string) (*Worktree, error) { + row := s.ro.QueryRowContext(ctx, s.ro.Rebind(` + SELECT + tsw.worktree_id, + tsw.session_id, + s.task_id, + tsw.repository_id, + r.local_path, + tsw.worktree_path, + tsw.worktree_branch, + s.base_branch, + tsw.status, + tsw.created_at, + tsw.updated_at, + tsw.merged_at, + tsw.deleted_at + FROM task_session_worktrees tsw + INNER JOIN task_sessions s ON tsw.session_id = s.id + LEFT JOIN repositories r ON tsw.repository_id = r.id + WHERE tsw.session_id = ? AND tsw.repository_id = ? AND tsw.status = ? + LIMIT 1 + `), sessionID, repositoryID, StatusActive) + return scanWorktreeRow(row) +} + +// Ensure SQLiteStore implements both Store and MultiRepoStore. +var ( + _ Store = (*SQLiteStore)(nil) + _ MultiRepoStore = (*SQLiteStore)(nil) +) diff --git a/apps/web/app/actions/workspaces.ts b/apps/web/app/actions/workspaces.ts index f43614f232..bb902f0264 100644 --- a/apps/web/app/actions/workspaces.ts +++ b/apps/web/app/actions/workspaces.ts @@ -9,7 +9,6 @@ import type { ListWorkflowStepsResponse, RepositoryDiscoveryResponse, ListRepositoriesResponse, - RepositoryBranchesResponse, LocalRepositoryStatusResponse, ListRepositoryScriptsResponse, ListWorkspacesResponse, @@ -165,16 +164,9 @@ export async function listRepositoriesAction( return fetchJson(url); } -export async function listLocalRepositoryBranchesAction( - workspaceId: string, - path: string, -): Promise { - const params = `?path=${encodeURIComponent(path)}`; - return fetchJson( - `${apiBaseUrl}/api/v1/workspaces/${workspaceId}/repositories/branches${params}`, - ); -} - +// Path-based branch listing moved to lib/api/domains/workspace-api.ts as a +// unified `listBranches({ repositoryId | path })` after the endpoint unification. +// Local repo status (branch + dirty files) backs the fresh-branch consent flow. export async function getLocalRepositoryStatusAction( workspaceId: string, path: string, diff --git a/apps/web/components/combobox.tsx b/apps/web/components/combobox.tsx index 0c70c7cc1e..17da8ce378 100644 --- a/apps/web/components/combobox.tsx +++ b/apps/web/components/combobox.tsx @@ -14,6 +14,7 @@ import { CommandList, } from "@kandev/ui/command"; import { Popover, PopoverContent, PopoverTrigger } from "@kandev/ui/popover"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; export type ComboboxOption = { value: string; @@ -21,6 +22,10 @@ export type ComboboxOption = { description?: string; keywords?: string[]; renderLabel?: () => React.ReactNode; + /** When true the option renders dimmed and isn't selectable. */ + disabled?: boolean; + /** Tooltip shown on hover when disabled is true. */ + disabledReason?: string; }; // Custom filter compatible with cmdk's `` prop. @@ -67,6 +72,34 @@ function TriggerLabel({ return {selectedOption?.label || placeholder}; } +function DisabledOptionItem({ option }: { option: ComboboxOption }) { + // cmdk's CommandItem swallows pointer events with no native tooltip slot, so + // we wrap the disabled item in a Tooltip trigger. The trigger is a plain + // div; CommandItem still owns keyboard nav and rendering. + const item = ( + +
+ {option.renderLabel ? option.renderLabel() : option.label} +
+
+ ); + if (!option.disabledReason) return item; + return ( + + +
{item}
+
+ {option.disabledReason} +
+ ); +} + function OptionsList({ options, value, @@ -78,25 +111,29 @@ function OptionsList({ }) { return ( - {options.map((option) => ( - onSelect(option.value)} - className="relative pr-7" - > -
- {option.renderLabel ? option.renderLabel() : option.label} -
- -
- ))} + {options.map((option) => + option.disabled ? ( + + ) : ( + onSelect(option.value)} + className="relative pr-7" + > +
+ {option.renderLabel ? option.renderLabel() : option.label} +
+ +
+ ), + )}
); } diff --git a/apps/web/components/diff/diff-viewer-resolver.tsx b/apps/web/components/diff/diff-viewer-resolver.tsx index 9089fa0014..4e3a8549b9 100644 --- a/apps/web/components/diff/diff-viewer-resolver.tsx +++ b/apps/web/components/diff/diff-viewer-resolver.tsx @@ -38,6 +38,8 @@ interface DiffViewerResolverProps { expandUnchanged?: boolean; /** Callback when expand-unchanged is toggled (pierre-diffs only) */ onToggleExpandUnchanged?: () => void; + /** Multi-repo subpath for the file (e.g. "kandev"); empty for single-repo. */ + repo?: string; } export const DiffViewerResolved = memo(function DiffViewerResolved(props: DiffViewerResolverProps) { @@ -52,6 +54,7 @@ export const DiffViewerResolved = memo(function DiffViewerResolved(props: DiffVi expandUnchanged, onToggleExpandUnchanged, onPreviewMarkdown, + repo, ...rest } = props; /* eslint-enable @typescript-eslint/no-unused-vars */ diff --git a/apps/web/components/diff/diff-viewer.tsx b/apps/web/components/diff/diff-viewer.tsx index 19e6cef17a..4bc7e68765 100644 --- a/apps/web/components/diff/diff-viewer.tsx +++ b/apps/web/components/diff/diff-viewer.tsx @@ -43,6 +43,8 @@ interface DiffViewerProps { expandUnchanged?: boolean; /** Callback when expand-unchanged is toggled (controlled mode) */ onToggleExpandUnchanged?: () => void; + /** Multi-repo subpath for the file (e.g. "kandev"); empty for single-repo. */ + repo?: string; } const SCALAR_PROP_KEYS: (keyof DiffViewerProps)[] = [ @@ -61,6 +63,7 @@ const SCALAR_PROP_KEYS: (keyof DiffViewerProps)[] = [ "baseRef", "expandUnchanged", "onToggleExpandUnchanged", + "repo", ]; const DATA_KEYS: (keyof FileDiffData)[] = ["filePath", "diff", "oldContent", "newContent"]; @@ -116,6 +119,71 @@ function NoDiffPlaceholder({ className }: { className?: string }) { ); } +type WiringArgs = { + data: FileDiffData; + state: ReturnType; + onCommentRun?: (comment: DiffComment) => void; + onOpenFile?: (filePath: string) => void; + onPreviewMarkdown?: (filePath: string) => void; + onRevert?: (filePath: string) => void; + enableComments: boolean; + enableExpansion: boolean; + hideHeader: boolean; + compact: boolean; + wordWrap: boolean; + setWordWrap: React.Dispatch>; + expandUnchanged: boolean; + toggleExpandUnchanged: () => void; + wrapperRef: React.RefObject; +}; + +/** + * Bundles the hover/annotation/options wiring for DiffViewer so the top-level + * component body stays under the 100-line cap. Equivalent to inlining the + * three calls — extracted only for size, no behavior change. + */ +function useDiffViewerWiring(args: WiringArgs) { + const { data, state, onCommentRun, wrapperRef, hideHeader, compact, enableExpansion } = args; + const { onLineEnter, onLineLeave, onButtonEnter, onButtonLeave } = useHunkHover({ + wrapperRef, + changeLineMapRef: state.changeLineMapRef, + hideTimeoutRef: state.hideTimeoutRef, + }); + const renderAnnotation = useAnnotationRenderer({ + handleRevertBlock: state.handleRevertBlock, + onButtonEnter, + onButtonLeave, + handleCommentSubmit: state.handleCommentSubmit, + handleCommentSubmitAndRun: state.handleCommentSubmitAndRun, + handleCommentUpdate: state.handleCommentUpdate, + handleCommentDelete: state.handleCommentDelete, + handleCommentRun: onCommentRun, + setShowCommentForm: state.setShowCommentForm, + setSelectedLines: state.setSelectedLines, + setEditingComment: state.setEditingComment, + }); + const showHeader = !hideHeader && !compact; + const canUseExpansion = useAutoLoadExpansion(enableExpansion, state); + const opts = useDiffOptions({ + filePath: data.filePath, + diff: data.diff, + enableComments: args.enableComments, + showHeader, + wordWrap: args.wordWrap, + setWordWrap: args.setWordWrap, + handleLineSelectionEnd: state.handleLineSelectionEnd, + onLineEnter, + onLineLeave, + onOpenFile: args.onOpenFile, + onPreviewMarkdown: args.onPreviewMarkdown, + onRevert: args.onRevert, + enableExpansion: canUseExpansion, + expandUnchanged: args.expandUnchanged, + onToggleExpandUnchanged: canUseExpansion ? args.toggleExpandUnchanged : undefined, + }); + return { ...opts, renderAnnotation }; +} + export const DiffViewer = memo(function DiffViewer({ data, enableComments = false, @@ -137,6 +205,7 @@ export const DiffViewer = memo(function DiffViewer({ baseRef, expandUnchanged: expandUnchangedProp, onToggleExpandUnchanged: onToggleExpandUnchangedProp, + repo, }: DiffViewerProps) { const [wordWrapLocal, setWordWrap] = useState(false); const wordWrap = wordWrapProp ?? wordWrapLocal; @@ -160,48 +229,27 @@ export const DiffViewer = memo(function DiffViewer({ onRevertBlock, enableExpansion, baseRef, + repo, }); - const { onLineEnter, onLineLeave, onButtonEnter, onButtonLeave } = useHunkHover({ - wrapperRef, - changeLineMapRef: state.changeLineMapRef, - hideTimeoutRef: state.hideTimeoutRef, - }); - - const renderAnnotation = useAnnotationRenderer({ - handleRevertBlock: state.handleRevertBlock, - onButtonEnter, - onButtonLeave, - handleCommentSubmit: state.handleCommentSubmit, - handleCommentSubmitAndRun: state.handleCommentSubmitAndRun, - handleCommentUpdate: state.handleCommentUpdate, - handleCommentDelete: state.handleCommentDelete, - handleCommentRun: onCommentRun, - setShowCommentForm: state.setShowCommentForm, - setSelectedLines: state.setSelectedLines, - setEditingComment: state.setEditingComment, - }); - - const showHeader = !hideHeader && !compact; - const canUseExpansion = useAutoLoadExpansion(enableExpansion, state); - - const { options, renderHeaderMetadata, renderHoverUtility } = useDiffOptions({ - filePath: data.filePath, - diff: data.diff, - enableComments, - showHeader, - wordWrap, - setWordWrap, - handleLineSelectionEnd: state.handleLineSelectionEnd, - onLineEnter, - onLineLeave, - onOpenFile, - onPreviewMarkdown, - onRevert, - enableExpansion: canUseExpansion, - expandUnchanged, - onToggleExpandUnchanged: canUseExpansion ? toggleExpandUnchanged : undefined, - }); + const { options, renderHeaderMetadata, renderHoverUtility, renderAnnotation } = + useDiffViewerWiring({ + data, + state, + onCommentRun, + onOpenFile, + onPreviewMarkdown, + onRevert, + enableComments, + enableExpansion, + hideHeader, + compact, + wordWrap, + setWordWrap, + expandUnchanged, + toggleExpandUnchanged, + wrapperRef, + }); const controlledSelection = state.showCommentForm ? state.selectedLines : null; diff --git a/apps/web/components/diff/file-diff-viewer.tsx b/apps/web/components/diff/file-diff-viewer.tsx index c53ce22dc6..754ea1b3e5 100644 --- a/apps/web/components/diff/file-diff-viewer.tsx +++ b/apps/web/components/diff/file-diff-viewer.tsx @@ -33,6 +33,8 @@ interface FileDiffViewerProps { expandUnchanged?: boolean; /** Callback when expand-unchanged is toggled (controlled mode) */ onToggleExpandUnchanged?: () => void; + /** Multi-repo subpath for the file (e.g. "kandev"); empty for single-repo. */ + repo?: string; } /** @@ -67,6 +69,7 @@ export const FileDiffViewer = memo(function FileDiffViewer({ baseRef, expandUnchanged, onToggleExpandUnchanged, + repo, }: FileDiffViewerProps) { const data = useMemo(() => transformGitDiff(filePath, diff, status), [filePath, diff, status]); @@ -92,6 +95,7 @@ export const FileDiffViewer = memo(function FileDiffViewer({ baseRef={baseRef} expandUnchanged={expandUnchanged} onToggleExpandUnchanged={onToggleExpandUnchanged} + repo={repo} /> ); }); diff --git a/apps/web/components/diff/use-diff-viewer-state.ts b/apps/web/components/diff/use-diff-viewer-state.ts index a66b7c5e89..69f1b7c1ae 100644 --- a/apps/web/components/diff/use-diff-viewer-state.ts +++ b/apps/web/components/diff/use-diff-viewer-state.ts @@ -136,6 +136,8 @@ type UseDiffViewerStateOpts = { enableExpansion?: boolean; /** Base git ref for fetching old content (e.g., "origin/main", "HEAD~1") */ baseRef?: string; + /** Multi-repo subpath for the file (e.g. "kandev"); empty for single-repo. */ + repo?: string; }; function useDiffViewerAnnotations({ @@ -320,6 +322,7 @@ export function useDiffViewerState(opts: UseDiffViewerStateOpts) { onRevertBlock, enableExpansion = false, baseRef, + repo, } = opts; const [selectedLines, setSelectedLines] = useState(null); @@ -348,6 +351,7 @@ export function useDiffViewerState(opts: UseDiffViewerStateOpts) { baseRef, fileDiffMetadata: baseDiffMetadata, enableExpansion, + repo, }); const { revertInfoRef, handleRevertBlock } = useRevertBlock(data.filePath, onRevertBlock); diff --git a/apps/web/components/diff/use-expandable-diff.ts b/apps/web/components/diff/use-expandable-diff.ts index 84567952ea..d434fc85eb 100644 --- a/apps/web/components/diff/use-expandable-diff.ts +++ b/apps/web/components/diff/use-expandable-diff.ts @@ -12,6 +12,8 @@ type UseExpandableDiffOptions = { baseRef: string | undefined; fileDiffMetadata: FileDiffMetadata | null; enableExpansion?: boolean; + /** Multi-repo subpath for the file (e.g. "kandev"); empty for single-repo. */ + repo?: string; }; type UseExpandableDiffResult = { @@ -36,9 +38,10 @@ async function fetchOldContent( sessionId: string, filePath: string, baseRef: string, + repo?: string, ): Promise { try { - const res = await requestFileContentAtRef(client, sessionId, filePath, baseRef); + const res = await requestFileContentAtRef(client, sessionId, filePath, baseRef, repo); if (res.is_binary) throw new Error("Cannot expand binary files"); if (!res.error) return res.content; // File not found at ref is expected for new files - return empty string @@ -57,11 +60,12 @@ async function fetchNewContent( client: WsClient, sessionId: string, filePath: string, + repo?: string, ): Promise { try { // Fetch from working tree (current file on disk), not HEAD. // The diff shows working tree changes, so newLines must match. - const res = await requestFileContent(client, sessionId, filePath); + const res = await requestFileContent(client, sessionId, filePath, repo); if (res.is_binary) throw new Error("Cannot expand binary files"); if (!res.error) return res.content; // File not found is expected for deleted files - return empty string @@ -80,11 +84,14 @@ async function fetchExpansionLines( sessionId: string, filePath: string, baseRef: string | undefined, + repo: string | undefined, ) { const client = getWebSocketClient(); if (!client) throw new Error("WebSocket client not available"); - const newContent = await fetchNewContent(client, sessionId, filePath); - const oldContent = baseRef ? await fetchOldContent(client, sessionId, filePath, baseRef) : ""; + const newContent = await fetchNewContent(client, sessionId, filePath, repo); + const oldContent = baseRef + ? await fetchOldContent(client, sessionId, filePath, baseRef, repo) + : ""; return { oldLines: oldContent.split(SPLIT_WITH_NEWLINES), newLines: newContent.split(SPLIT_WITH_NEWLINES), @@ -104,6 +111,7 @@ export function useExpandableDiff({ baseRef, fileDiffMetadata, enableExpansion = false, + repo, }: UseExpandableDiffOptions): UseExpandableDiffResult { const requestVersionRef = useRef(0); const [loadedContent, setLoadedContent] = useState<{ @@ -121,7 +129,7 @@ export function useExpandableDiff({ requestVersionRef.current += 1; setLoadedContent(null); setError(null); - }, [sessionId, filePath, baseRef, fileDiffMetadata]); + }, [sessionId, filePath, baseRef, repo, fileDiffMetadata]); const loadContent = useCallback(async () => { if (!sessionId || !enableExpansion || loadedContent || isLoading) return; @@ -131,7 +139,7 @@ export function useExpandableDiff({ setError(null); try { - const lines = await fetchExpansionLines(sessionId, filePath, baseRef); + const lines = await fetchExpansionLines(sessionId, filePath, baseRef, repo); if (version === requestVersionRef.current) setLoadedContent(lines); } catch (err) { const msg = err instanceof Error ? err.message : "Failed to load file content"; @@ -140,7 +148,7 @@ export function useExpandableDiff({ } finally { setIsLoading(false); } - }, [sessionId, filePath, baseRef, enableExpansion, loadedContent, isLoading]); + }, [sessionId, filePath, baseRef, repo, enableExpansion, loadedContent, isLoading]); const metadata = useMemo(() => { if (!fileDiffMetadata) return null; diff --git a/apps/web/components/github/pr-detail-panel.tsx b/apps/web/components/github/pr-detail-panel.tsx index 3a35e31341..875a9e6e14 100644 --- a/apps/web/components/github/pr-detail-panel.tsx +++ b/apps/web/components/github/pr-detail-panel.tsx @@ -17,7 +17,7 @@ import { Separator } from "@kandev/ui/separator"; import { ScrollArea } from "@kandev/ui/scroll-area"; import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; import { useAppStore } from "@/components/state-provider"; -import { useActiveTaskPR } from "@/hooks/domains/github/use-task-pr"; +import { useActiveTaskPR, useTaskPR } from "@/hooks/domains/github/use-task-pr"; import { prPanelLabel } from "@/components/github/pr-utils"; import { usePRFeedback } from "@/hooks/domains/github/use-pr-feedback"; import { useCommentsStore } from "@/lib/state/slices/comments"; @@ -41,12 +41,21 @@ import { CommentsSection } from "./pr-comments-section"; type PRDetailPanelProps = { panelId: string; + /** Per-PR params; multi-repo panels carry prKey="//". */ + params?: { prKey?: string }; }; -export function PRDetailPanelComponent({ panelId }: PRDetailPanelProps) { - const pr = useActiveTaskPR(); +export function PRDetailPanelComponent({ panelId, params }: PRDetailPanelProps) { + const activeTaskId = useAppStore((s) => s.tasks.activeTaskId); + const { prs } = useTaskPR(activeTaskId); + const activePR = useActiveTaskPR(); const sessionId = useAppStore((s) => s.tasks.activeSessionId); + // Multi-repo: when the panel was opened with a prKey, render the matching + // TaskPR. Falls back to the active (primary) PR for legacy single-repo + // panels that pre-date the prKey param. + const pr = (params?.prKey ? prs.find((p) => prTaskKey(p) === params.prKey) : null) ?? activePR; + useEffect(() => { const title = pr ? prPanelLabel(pr.pr_number) : "Pull Request"; setPanelTitle(panelId, title); @@ -67,6 +76,11 @@ export function PRDetailPanelComponent({ panelId }: PRDetailPanelProps) { ); } +/** Stable per-PR key used by addPRPanel and the multi-PR topbar buttons. */ +export function prTaskKey(pr: TaskPR): string { + return `${pr.owner}/${pr.repo}/${pr.pr_number}`; +} + // --- Add PR feedback as chat context --- function useAddPRFeedbackAsContext(sessionId: string, prNumber: number) { diff --git a/apps/web/components/github/pr-task-icon.test.ts b/apps/web/components/github/pr-task-icon.test.ts index b9e438efcb..613ff04f7b 100644 --- a/apps/web/components/github/pr-task-icon.test.ts +++ b/apps/web/components/github/pr-task-icon.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect } from "vitest"; import { + aggregatePRStatusColor, getPRStatusColor, getPRTooltip, isPRAwaitingReview, @@ -269,6 +270,43 @@ describe("isPRAwaitingReview", () => { }); }); +describe("aggregatePRStatusColor", () => { + it("returns muted for empty list", () => { + expect(aggregatePRStatusColor([])).toBe("text-muted-foreground"); + }); + + it("surfaces the worst-of state — one red dominates a green sibling", () => { + const green = makePR({ + state: "open", + review_state: "approved", + checks_state: "success", + mergeable_state: "clean", + }); + const red = makePR({ + state: "open", + review_state: "changes_requested", + checks_state: "success", + }); + expect(aggregatePRStatusColor([green, red])).toBe("text-red-500"); + }); + + it("returns emerald only when all PRs are ready to merge", () => { + const ready = makePR({ + state: "open", + review_state: "approved", + checks_state: "success", + mergeable_state: "clean", + }); + expect(aggregatePRStatusColor([ready, ready])).toBe("text-emerald-400"); + }); + + it("yellow CI pending beats merged purple", () => { + const pending = makePR({ state: "open", checks_state: "pending" }); + const merged = makePR({ state: "merged" }); + expect(aggregatePRStatusColor([merged, pending])).toBe("text-yellow-500"); + }); +}); + describe("getPRTooltip", () => { it("includes 'Ready to merge' when ready", () => { const pr = makePR({ diff --git a/apps/web/components/github/pr-task-icon.tsx b/apps/web/components/github/pr-task-icon.tsx index 3561e1c64d..f15cdf0159 100644 --- a/apps/web/components/github/pr-task-icon.tsx +++ b/apps/web/components/github/pr-task-icon.tsx @@ -6,6 +6,18 @@ import { cn } from "@/lib/utils"; import { useAppStore } from "@/components/state-provider"; import type { TaskPR } from "@/lib/types/github"; +const STATUS_RANK: Record = { + // Higher = more attention-worthy. Drives the aggregated icon color when a + // task has multiple PRs (we surface the worst state). + "text-red-500": 5, + "text-yellow-500": 4, + "text-sky-400": 3, + "text-emerald-400": 2, + "text-green-500": 1, + "text-purple-500": 0, + "text-muted-foreground": 0, +}; + // Requires checks_state === "success" (not just "") so repos with no CI configured // won't trigger ready-to-merge on mergeable_state=clean alone. export function isPRReadyToMerge(pr: TaskPR): boolean { @@ -62,17 +74,41 @@ export function getPRTooltip(pr: TaskPR): string { return parts.join(" | "); } +/** + * Picks the most attention-worthy color across N PRs. For multi-repo tasks one + * red PR should dominate the visual even if the others are green. + */ +export function aggregatePRStatusColor(prs: TaskPR[]): string { + if (prs.length === 0) return "text-muted-foreground"; + let bestColor = "text-muted-foreground"; + let bestRank = -1; + for (const pr of prs) { + const color = getPRStatusColor(pr); + const rank = STATUS_RANK[color] ?? 0; + if (rank > bestRank) { + bestRank = rank; + bestColor = color; + } + } + return bestColor; +} + export function PRTaskIcon({ taskId }: { taskId: string }) { - const pr = useAppStore((state) => state.taskPRs.byTaskId[taskId] ?? null); + const prs = useAppStore((state) => state.taskPRs.byTaskId[taskId] ?? null); - if (!pr) return null; + if (!prs || prs.length === 0) return null; + if (prs.length === 1) return ; + return ; +} +function SinglePRIcon({ taskId, pr }: { taskId: string; pr: TaskPR }) { return ( @@ -83,3 +119,35 @@ export function PRTaskIcon({ taskId }: { taskId: string }) { ); } + +function MultiPRIcon({ taskId, prs }: { taskId: string; prs: TaskPR[] }) { + const aggregateColor = aggregatePRStatusColor(prs); + const allReady = prs.every(isPRReadyToMerge); + return ( + + + + + {prs.length} + + + +
+ {prs.map((pr) => ( +
+ + + + {getPRTooltip(pr)} +
+ ))} +
+
+
+ ); +} diff --git a/apps/web/components/github/pr-topbar-button.tsx b/apps/web/components/github/pr-topbar-button.tsx index 74ce4ef58d..f3e37ca7b1 100644 --- a/apps/web/components/github/pr-topbar-button.tsx +++ b/apps/web/components/github/pr-topbar-button.tsx @@ -1,12 +1,31 @@ "use client"; import { memo } from "react"; -import { IconGitPullRequest, IconCheck, IconX, IconClock } from "@tabler/icons-react"; +import { + IconGitPullRequest, + IconCheck, + IconX, + IconClock, + IconChevronDown, +} from "@tabler/icons-react"; import { Button } from "@kandev/ui/button"; import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@kandev/ui/dropdown-menu"; import { useDockviewStore } from "@/lib/state/dockview-store"; -import { useActiveTaskPR, useTaskPR } from "@/hooks/domains/github/use-task-pr"; -import { getPRStatusColor, isPRReadyToMerge } from "@/components/github/pr-task-icon"; +import { useTaskPR } from "@/hooks/domains/github/use-task-pr"; +import { + aggregatePRStatusColor, + getPRStatusColor, + isPRReadyToMerge, +} from "@/components/github/pr-task-icon"; +import { prTaskKey } from "@/components/github/pr-detail-panel"; import { useAppStore } from "@/components/state-provider"; import type { TaskPR } from "@/lib/types/github"; @@ -36,31 +55,86 @@ function PRStatusIcon({ pr }: { pr: TaskPR }) { export const PRTopbarButton = memo(function PRTopbarButton() { const activeTaskId = useAppStore((s) => s.tasks.activeTaskId); - // useTaskPR fetches if not in store; useActiveTaskPR just reads - useTaskPR(activeTaskId); - const pr = useActiveTaskPR(); + // useTaskPR fetches if not in store and returns the full per-task list so + // multi-repo tasks can surface every PR (one button for single-repo, a + // dropdown summary for 2+ so the topbar doesn't blow up horizontally). + const { prs } = useTaskPR(activeTaskId); + + if (prs.length === 0) return null; + if (prs.length === 1) return ; + return ; +}); + +function PRSingleButton({ pr }: { pr: TaskPR }) { const addPRPanel = useDockviewStore((s) => s.addPRPanel); - - if (!pr) return null; - + const tooltip = `${pr.owner}/${pr.repo} #${pr.pr_number} — ${pr.pr_title}`; return ( - {pr.pr_title} + {tooltip} ); -}); +} + +function PRMultiButton({ prs }: { prs: TaskPR[] }) { + const addPRPanel = useDockviewStore((s) => s.addPRPanel); + const aggColor = aggregatePRStatusColor(prs); + return ( + + + + + + + + {prs.length} pull requests linked to this task — open one + + + Pull requests + + {prs.map((pr) => ( + addPRPanel(prTaskKey(pr))} + className="cursor-pointer gap-2" + data-testid={`pr-topbar-menu-item-${pr.pr_number}`} + > + +
+ + {pr.repo} #{pr.pr_number} + + {pr.pr_title} +
+ +
+ ))} +
+
+ ); +} diff --git a/apps/web/components/kanban-card.tsx b/apps/web/components/kanban-card.tsx index 8beafcbe33..804d893c67 100644 --- a/apps/web/components/kanban-card.tsx +++ b/apps/web/components/kanban-card.tsx @@ -13,6 +13,7 @@ import { import { Checkbox } from "@kandev/ui/checkbox"; import { Card, CardContent } from "@kandev/ui/card"; import { Badge } from "@kandev/ui/badge"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; import { TaskDeleteConfirmDialog } from "@/components/task/task-delete-confirm-dialog"; import { DropdownMenu, @@ -42,6 +43,8 @@ export interface Task { description?: string; position?: number; repositoryId?: string; + /** All repositories linked to the task; used to render a "+N" chip for multi-repo. */ + repositories?: Array<{ id: string; repository_id: string; position: number }>; // Workflow fields sessionCount?: number | null; primarySessionId?: string | null; @@ -63,7 +66,8 @@ export interface WorkflowStep { interface KanbanCardProps { task: Task; - repositoryName?: string | null; + /** Display names of every repository linked to the task, primary first. */ + repositoryNames?: string[]; onClick?: (task: Task) => void; onEdit?: (task: Task) => void; onDelete?: (task: Task) => void; @@ -79,24 +83,58 @@ interface KanbanCardProps { isMultiSelectMode?: boolean; } +const REPO_CHIPS_VISIBLE = 2; + +function RepoChipRow({ repoNames }: { repoNames: string[] }) { + if (repoNames.length === 0) return null; + const visible = repoNames.slice(0, REPO_CHIPS_VISIBLE); + const overflow = repoNames.slice(REPO_CHIPS_VISIBLE); + const row = ( +
+ {visible.map((name) => ( + + {name} + + ))} + {overflow.length > 0 && ( + + +{overflow.length} + + )} +
+ ); + if (repoNames.length <= 1) return row; + return ( + + {row} + +
+ {repoNames.map((name) => ( + {name} + ))} +
+
+
+ ); +} + function KanbanCardBody({ task, - repoName, + repoNames, actions, }: { task: Task; - repoName: string | null; + repoNames: string[]; actions?: React.ReactNode; }) { return ( <>
- {repoName && ( -

- {repoName} -

- )} +

- + ); @@ -419,7 +457,7 @@ function KanbanCardCheckbox({ export function KanbanCard({ task, - repositoryName, + repositoryNames, onClick, onEdit, onDelete, @@ -492,7 +530,7 @@ export function KanbanCard({

state.repositories.itemsByWorkspaceId); - const repository = useMemo( - () => - (Object.values(repositoriesByWorkspace).flat() as Repository[]).find( - (repo) => repo.id === task.repositoryId, - ) ?? null, - [repositoriesByWorkspace, task.repositoryId], + const repositoryNames = useMemo( + () => resolveTaskRepositoryNames(task, Object.values(repositoriesByWorkspace).flat()), + [repositoriesByWorkspace, task], ); - const repositoryName = repository ? getRepositoryDisplayName(repository.local_path) : null; return ( ); } + +/** + * Resolves a task's linked repositories to display names. Primary first + * (`task.repositoryId`), then any others ordered by `task.repositories[].position`. + * Skips unresolved IDs (repo deleted / not yet hydrated). + */ +export function resolveTaskRepositoryNames(task: Task, repositories: Repository[]): string[] { + const byId = new Map(repositories.map((repo) => [repo.id, repo])); + const seen = new Set(); + const names: string[] = []; + + const push = (id: string | undefined) => { + if (!id || seen.has(id)) return; + const repo = byId.get(id); + if (!repo) return; + seen.add(id); + const display = getRepositoryDisplayName(repo.local_path); + if (display) names.push(display); + }; + + push(task.repositoryId); + const ordered = [...(task.repositories ?? [])].sort((a, b) => a.position - b.position); + for (const link of ordered) push(link.repository_id); + return names; +} diff --git a/apps/web/components/kanban-column.tsx b/apps/web/components/kanban-column.tsx index f0e7887475..ed2c794656 100644 --- a/apps/web/components/kanban-column.tsx +++ b/apps/web/components/kanban-column.tsx @@ -2,9 +2,9 @@ import { useMemo } from "react"; import { useDroppable } from "@dnd-kit/core"; -import { KanbanCard, Task } from "./kanban-card"; +import { KanbanCard, resolveTaskRepositoryNames, Task } from "./kanban-card"; import { Badge } from "@kandev/ui/badge"; -import { cn, getRepositoryDisplayName } from "@/lib/utils"; +import { cn } from "@/lib/utils"; import { useAppStore } from "@/components/state-provider"; import type { Repository } from "@/lib/types/http"; @@ -66,13 +66,6 @@ export function KanbanColumn({ [repositoriesByWorkspace], ); - // Helper function to get repository name for a task - const getRepositoryName = (repositoryId?: string): string | null => { - if (!repositoryId) return null; - const repository = repositories.find((repo) => repo.id === repositoryId); - return repository ? getRepositoryDisplayName(repository.local_path) : null; - }; - return (
, files: CumulativeDiff["files"], gitStatusFiles: Record | null, ) { - for (const [path, file] of Object.entries(files)) { - if (fileMap.has(path)) continue; + // Multi-repo: backend always stamps each per-file payload with + // `repository_name` + the repo-relative `path`. Single-repo (or legacy) + // payloads keep the bare path on the map key. We trust `file.path` to be + // set in the multi-repo shape — a missing `path` would be a backend + // contract violation and indicates the caller is on an outdated agentctl; + // surface it loudly via a console warning rather than silently injecting + // the composite map key into the displayed path (which used to corrupt + // the file tree node names). + for (const [mapKey, file] of Object.entries(files)) { + const repoName = file.repository_name; + const path = file.path; + if (!path) { + console.warn("[review] cumulative diff entry missing `path` field; skipping", { + mapKey, + repoName, + }); + continue; + } + const key = fileMapKey(path, repoName); + if (fileMap.has(key)) continue; const diff = file.diff ? normalizeDiffContent(file.diff) : ""; if (diff) { - const hasUncommitted = gitStatusFiles != null && path in gitStatusFiles; - fileMap.set(path, { + const matchingUncommitted = findUncommittedByPathAndRepo(gitStatusFiles, path, repoName); + fileMap.set(key, { path, diff, status: file.status || "modified", additions: file.additions ?? 0, deletions: file.deletions ?? 0, - staged: gitStatusFiles?.[path]?.staged ?? false, - source: hasUncommitted ? "uncommitted" : "committed", + staged: matchingUncommitted?.staged ?? false, + source: matchingUncommitted ? "uncommitted" : "committed", + repository_name: repoName, }); } } } +/** Looks up a FileInfo in the (possibly composite-keyed) gitStatus map by + * (path, repository_name). When repo is undefined or empty (single-repo), + * returns the first match by path. */ +function findUncommittedByPathAndRepo( + gitStatusFiles: Record | null, + path: string, + repositoryName: string | undefined, +): FileInfo | undefined { + if (!gitStatusFiles) return undefined; + for (const file of Object.values(gitStatusFiles)) { + if (file.path !== path) continue; + if (!repositoryName) return file; + if (file.repository_name === repositoryName) return file; + } + return undefined; +} + function addUncommittedFiles( fileMap: Map, gitStatusFiles: Record, ) { - for (const [path, file] of Object.entries(gitStatusFiles)) { - if (fileMap.has(path)) continue; + for (const file of Object.values(gitStatusFiles)) { + const key = fileMapKey(file.path, file.repository_name); + if (fileMap.has(key)) continue; const diff = file.diff ? normalizeDiffContent(file.diff) : ""; if (diff) - fileMap.set(path, { - path, + fileMap.set(key, { + path: file.path, diff, status: file.status, additions: file.additions ?? 0, deletions: file.deletions ?? 0, staged: file.staged, source: "uncommitted", + repository_name: file.repository_name, }); } } @@ -113,26 +169,54 @@ function computeReviewSets( const reviewed = new Set(); const stale = new Set(); for (const file of allFiles) { - const reviewState = reviews.get(file.path); + const key = reviewFileKey(file); + const reviewState = reviews.get(key); if (!reviewState?.reviewed) continue; const currentHash = hashDiff(file.diff); - if (reviewState.diffHash && reviewState.diffHash !== currentHash) stale.add(file.path); - else reviewed.add(file.path); + if (reviewState.diffHash && reviewState.diffHash !== currentHash) stale.add(key); + else reviewed.add(key); } return { reviewedFiles: reviewed, staleFiles: stale }; } +/** + * Counts diff comments per file, scoped by repo when known. Multi-repo: + * comments carrying `repositoryId` are matched only against files in that + * repo (translated from `repository_name` via `repositoryNameToId`); legacy + * comments without `repositoryId` and same-repo unattributed comments + * match by path. Returned record is keyed by `reviewFileKey(file)` so the + * file tree's per-row badge correctly disambiguates same-named files. + */ function computeCommentCounts( byId: Record, sessionCommentIds: string[] | undefined, + allFiles: ReviewFile[], + repositoryNameToId: Map, ): Record { const counts: Record = {}; if (!sessionCommentIds) return counts; + + type BucketKey = string; + const bucketKey = (repoId: string, path: string) => `${repoId}::${path}`; + const bucket = new Map(); for (const id of sessionCommentIds) { const comment = byId[id]; - if (comment && isDiffComment(comment)) { - counts[comment.filePath] = (counts[comment.filePath] ?? 0) + 1; + if (!comment || !isDiffComment(comment)) continue; + const k = bucketKey(comment.repositoryId ?? "", comment.filePath); + bucket.set(k, (bucket.get(k) ?? 0) + 1); + } + + for (const file of allFiles) { + let total = 0; + if (file.repository_name) { + const repoId = repositoryNameToId.get(file.repository_name) ?? ""; + if (repoId) total += bucket.get(bucketKey(repoId, file.path)) ?? 0; } + // Path-only comments (legacy / pre-multi-repo data) match every file + // sharing the path; this preserves the prior single-repo behavior and + // keeps existing comments visible after the multi-repo rollout. + total += bucket.get(bucketKey("", file.path)) ?? 0; + if (total > 0) counts[reviewFileKey(file)] = total; } return counts; } @@ -157,18 +241,20 @@ function useReviewDialogHandlers(opts: ReviewDialogHandlerOptions) { window.dispatchEvent(new CustomEvent("diff-view-mode-change", { detail: mode })); }, []); - const handleSelectFile = useCallback((path: string, setSelectedFile: (p: string) => void) => { - setSelectedFile(path); + const handleSelectFile = useCallback((key: string, setSelectedFile: (k: string) => void) => { + setSelectedFile(key); // Note: scrolling is now handled by FileDiffSection when isSelected changes // This ensures proper timing after the section expands }, []); const handleToggleReviewed = useCallback( - (path: string, reviewed: boolean) => { + (key: string, reviewed: boolean) => { if (reviewed) { - const file = allFiles.find((f) => f.path === path); - markReviewed(path, file ? hashDiff(file.diff) : ""); - } else markUnreviewed(path); + // Look up by composite key so two same-name files in different repos + // don't share their reviewed/diff-hash. + const file = allFiles.find((f) => reviewFileKey(f) === key); + markReviewed(key, file ? hashDiff(file.diff) : ""); + } else markUnreviewed(key); }, [allFiles, markReviewed, markUnreviewed], ); @@ -182,9 +268,12 @@ function useReviewDialogHandlers(opts: ReviewDialogHandlerOptions) { ); const handleDiscard = useCallback( - async (path: string) => { + async (key: string) => { + // Caller passes the composite key; split out the repo + path so + // discard runs in the correct repo's worktree. + const { repositoryName, path } = splitFileKey(key); try { - const result = await discard([path]); + const result = await discard([path], repositoryName || undefined); if (result.success) toast({ title: "Changes discarded", description: path, variant: "success" }); else @@ -252,17 +341,33 @@ function useReviewDialogState(props: ReviewDialogProps) { () => computeReviewSets(allFiles, reviews), [allFiles, reviews], ); + // Build a `repository_name` → `repositoryId` map from the workspace repos + // slice. Comments store `repositoryId` (UUID) but ReviewFiles carry + // `repository_name` (e.g. "kandev"); the dialog needs both to scope the + // per-repo comment counts. Falls back to an empty map (legacy single-repo + // counts by path only). + const reposByWorkspace = useAppStore((s) => s.repositories.itemsByWorkspaceId); + const repositoryNameToId = useMemo(() => { + const m = new Map(); + for (const list of Object.values(reposByWorkspace)) { + for (const r of list) m.set(r.name, r.id); + } + return m; + }, [reposByWorkspace]); const commentCountByFile = useMemo( - () => computeCommentCounts(byId, sessionCommentIds), - [byId, sessionCommentIds], + () => computeCommentCounts(byId, sessionCommentIds, allFiles, repositoryNameToId), + [byId, sessionCommentIds, allFiles, repositoryNameToId], ); const totalCommentCount = useMemo( () => Object.values(commentCountByFile).reduce((sum, c) => sum + c, 0), [commentCountByFile], ); + // Composite-key the file refs so two files with the same path in different + // repos get distinct refs (otherwise auto-scroll-on-select / "selected" + // styling lands on the wrong row). const fileRefs = useMemo(() => { const refs = new Map>(); - for (const file of allFiles) refs.set(file.path, createRef()); + for (const file of allFiles) refs.set(reviewFileKey(file), createRef()); return refs; }, [allFiles]); const prevCountRef = useRef(null); diff --git a/apps/web/components/review/review-diff-list-grouping.test.tsx b/apps/web/components/review/review-diff-list-grouping.test.tsx new file mode 100644 index 0000000000..54afa2cd70 --- /dev/null +++ b/apps/web/components/review/review-diff-list-grouping.test.tsx @@ -0,0 +1,122 @@ +import { describe, it, expect, vi, afterEach } from "vitest"; +import { render, screen, cleanup } from "@testing-library/react"; +import { createRef, type ReactNode } from "react"; +import { TooltipProvider } from "@kandev/ui/tooltip"; +import type { ReviewFile } from "./types"; + +// FileDiffViewer is heavy and irrelevant to the grouping test — stub it. +vi.mock("@/components/diff", () => ({ + FileDiffViewer: ({ filePath }: { filePath: string }) => ( +
{filePath}
+ ), +})); + +vi.mock("@/components/editors/file-actions-dropdown", () => ({ + FileActionsDropdown: () => null, +})); + +vi.mock("@/lib/ws/connection", () => ({ getWebSocketClient: () => null })); +vi.mock("@/lib/ws/workspace-files", () => ({ + requestFileContent: vi.fn(), + updateFileContent: vi.fn(), +})); +vi.mock("@/hooks/use-global-view-mode", () => ({ + useGlobalViewMode: () => ["unified", vi.fn()], +})); +vi.mock("@/hooks/domains/comments/use-run-comment", () => ({ + useRunComment: () => ({ runComment: vi.fn() }), +})); +vi.mock("@/components/state-provider", () => ({ + useAppStore: () => null, +})); +vi.mock("@/components/toast-provider", () => ({ + useToast: () => ({ toast: vi.fn() }), +})); + +import { ReviewDiffList } from "./review-diff-list"; + +afterEach(cleanup); + +function file(path: string, repo?: string): ReviewFile { + return { + path, + diff: "@@ -1 +1 @@\n-a\n+b\n", + status: "modified", + additions: 1, + deletions: 1, + staged: false, + source: "uncommitted", + repository_name: repo, + }; +} + +const baseProps = { + reviewedFiles: new Set(), + staleFiles: new Set(), + sessionId: "sess", + autoMarkOnScroll: false, + wordWrap: false, + selectedFile: null, + onToggleReviewed: () => undefined, + onDiscard: () => undefined, + fileRefs: new Map>(), +}; + +// FileDiffSection's toolbar uses Radix Tooltip which needs a provider. +function withTooltips(node: ReactNode) { + return {node}; +} + +describe("ReviewDiffList — multi-repo grouping", () => { + it("renders no repo header when all files lack a repository_name", () => { + const refs = new Map([["a.ts", createRef()]]); + render(withTooltips()); + expect(screen.queryAllByTestId("changes-repo-header")).toHaveLength(0); + }); + + it("renders one header per repo when 2+ repos are present", () => { + const files: ReviewFile[] = [ + file("src/app.tsx", "frontend"), + file("src/api.ts", "frontend"), + file("handlers/task.go", "backend"), + ]; + const refs = new Map(files.map((f) => [f.path, createRef()])); + render(withTooltips()); + + const headers = screen.getAllByTestId("changes-repo-header"); + expect(headers).toHaveLength(2); + expect(headers[0].textContent).toContain("frontend"); + expect(headers[0].textContent).toContain("2 files"); + expect(headers[1].textContent).toContain("backend"); + expect(headers[1].textContent).toContain("1 file"); + }); + + it("shows a header for a single named repo too (so user sees the label)", () => { + const refs = new Map([["a.ts", createRef()]]); + render( + withTooltips( + , + ), + ); + expect(screen.getByTestId("changes-repo-header").textContent).toContain("only-repo"); + }); + + it("groups files into per-repo sections with the right counts", () => { + const files: ReviewFile[] = [file("a.ts", "x"), file("b.ts", "y"), file("c.ts", "x")]; + const refs = new Map(files.map((f) => [f.path, createRef()])); + render(withTooltips()); + const groups = screen.getAllByTestId("changes-repo-group"); + expect(groups).toHaveLength(2); + // The diff body itself is lazy-loaded via IntersectionObserver (which + // doesn't fire in happy-dom), so verify grouping by counting file path + // labels in each group's header rather than the inner diff body. + const xGroup = groups.find((g) => g.getAttribute("data-repository-name") === "x"); + const yGroup = groups.find((g) => g.getAttribute("data-repository-name") === "y"); + // FileDiffHeader always renders the file path in a span; count those. + expect(xGroup?.textContent).toContain("a.ts"); + expect(xGroup?.textContent).toContain("c.ts"); + expect(xGroup?.textContent).not.toContain("b.ts"); + expect(yGroup?.textContent).toContain("b.ts"); + expect(yGroup?.textContent).not.toContain("a.ts"); + }); +}); diff --git a/apps/web/components/review/review-diff-list-groups.tsx b/apps/web/components/review/review-diff-list-groups.tsx new file mode 100644 index 0000000000..2f3d054ed0 --- /dev/null +++ b/apps/web/components/review/review-diff-list-groups.tsx @@ -0,0 +1,24 @@ +"use client"; + +/** + * Sticky per-repository section header rendered above each group of files in + * the changes panel. The grouping logic lives in @/lib/group-by-repo so the + * Review dialog and the dockview Changes tab share the same Map-based + * bucketer. Only this presentational header is review-specific. + */ +export function RepoGroupHeader({ name, fileCount }: { name: string; fileCount: number }) { + // The empty-name group ("uncategorised" — no repository_name on its files) + // gets a generic label so the user understands what they're looking at. + const label = name || "Other changes"; + return ( +
+ {label} + + {fileCount} {fileCount === 1 ? "file" : "files"} + +
+ ); +} diff --git a/apps/web/components/review/review-diff-list.tsx b/apps/web/components/review/review-diff-list.tsx index f1a3311189..60f0fef5f9 100644 --- a/apps/web/components/review/review-diff-list.tsx +++ b/apps/web/components/review/review-diff-list.tsx @@ -1,41 +1,22 @@ "use client"; -import { memo, useEffect, useRef, useState, useCallback } from "react"; -import { - IconAlertTriangle, - IconArrowBackUp, - IconChevronDown, - IconChevronRight, - IconCopy, - IconFold, - IconFoldDown, - IconLayoutColumns, - IconLayoutRows, - IconPencil, - IconTextWrap, - IconEye, -} from "@tabler/icons-react"; +import { memo, useEffect, useMemo, useRef, useState, useCallback } from "react"; +import { IconAlertTriangle, IconChevronDown, IconChevronRight } from "@tabler/icons-react"; import { Checkbox } from "@kandev/ui/checkbox"; -import { Button } from "@kandev/ui/button"; -import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; import { FileDiffViewer } from "@/components/diff"; import type { RevertBlockInfo } from "@/components/diff"; -import { FileActionsDropdown } from "@/components/editors/file-actions-dropdown"; import { getWebSocketClient } from "@/lib/ws/connection"; import { requestFileContent, updateFileContent } from "@/lib/ws/workspace-files"; import { generateUnifiedDiff, calculateHash } from "@/lib/utils/file-diff"; -import { useGlobalViewMode } from "@/hooks/use-global-view-mode"; import { useAppStore } from "@/components/state-provider"; import { useToast } from "@/components/toast-provider"; import { useRunComment } from "@/hooks/domains/comments/use-run-comment"; import type { DiffComment } from "@/lib/diff/types"; -import { diffSkipReasonLabel } from "./types"; +import { diffSkipReasonLabel, reviewFileKey } from "./types"; import type { ReviewFile } from "./types"; - -function isMarkdownPath(filePath: string): boolean { - const ext = filePath.split(".").pop()?.toLowerCase(); - return ext === "md" || ext === "mdx"; -} +import { RepoGroupHeader } from "./review-diff-list-groups"; +import { FileDiffToolbar } from "./review-diff-toolbar"; +import { groupByRepositoryName } from "@/lib/group-by-repo"; type ReviewDiffListProps = { files: ReviewFile[]; @@ -67,35 +48,68 @@ export const ReviewDiffList = memo(function ReviewDiffList({ fileRefs, }: ReviewDiffListProps) { const scrollContainerRef = useRef(null); - // Find index of selected file - we need to force-load all files up to it - const selectedIndex = selectedFile ? files.findIndex((f) => f.path === selectedFile) : -1; + // All in-memory state (selectedFile, reviewedFiles, staleFiles, fileRefs) + // is keyed by `reviewFileKey(file)` so two files at the same path in + // different repos (e.g. `kandev/README.md` + `lvc/README.md`) get + // distinct rows. Without this every multi-repo review with same-named + // files would mark them all reviewed when one is checked. + const selectedIndex = selectedFile + ? files.findIndex((f) => reviewFileKey(f) === selectedFile) + : -1; + const groups = useMemo(() => groupByRepositoryName(files, (f) => f.repository_name), [files]); + const showRepoHeaders = groups.length > 1 || (groups[0]?.repositoryName ?? "") !== ""; return (
- {files.map((file, index) => ( - = 0 && index <= selectedIndex} - onToggleReviewed={onToggleReviewed} - onDiscard={onDiscard} - onOpenFile={onOpenFile} - onPreviewMarkdown={onPreviewMarkdown} - sectionRef={fileRefs.get(file.path)} - scrollContainer={scrollContainerRef} - /> + {groups.map((group) => ( +
+ {showRepoHeaders && ( + + )} + {group.items.map((file) => { + const key = reviewFileKey(file); + return ( + = 0 && + files.findIndex((f) => reviewFileKey(f) === key) <= selectedIndex + } + onToggleReviewed={onToggleReviewed} + onDiscard={onDiscard} + onOpenFile={onOpenFile} + onPreviewMarkdown={onPreviewMarkdown} + sectionRef={fileRefs.get(key)} + scrollContainer={scrollContainerRef} + /> + ); + })} +
))}
); }); +// Per-repo grouping helpers live in ./review-diff-list-groups so this file +// stays under the 600-line lint cap. + type FileDiffSectionProps = { file: ReviewFile; + /** Composite per-file key from `reviewFileKey(file)` — used as the arg + * to `onToggleReviewed` / `onDiscard` so callers can disambiguate + * same-named files in different repos. */ + fileKey: string; isReviewed: boolean; isStale: boolean; sessionId: string; @@ -103,8 +117,8 @@ type FileDiffSectionProps = { wordWrap: boolean; isSelected?: boolean; forceLoad?: boolean; - onToggleReviewed: (path: string, reviewed: boolean) => void; - onDiscard: (path: string) => void; + onToggleReviewed: (key: string, reviewed: boolean) => void; + onDiscard: (key: string) => void; onOpenFile?: (filePath: string) => void; onPreviewMarkdown?: (filePath: string) => void; sectionRef?: React.RefObject; @@ -136,8 +150,9 @@ type AutoMarkArgs = { autoMarkOnScroll: boolean; isReviewed: boolean; isStale: boolean; - filePath: string; - onToggleReviewed: (path: string, reviewed: boolean) => void; + /** Composite per-file key (matches what onToggleReviewed expects). */ + fileKey: string; + onToggleReviewed: (key: string, reviewed: boolean) => void; scrollContainer: React.RefObject; }; @@ -145,7 +160,7 @@ function useAutoMarkOnScroll({ autoMarkOnScroll, isReviewed, isStale, - filePath, + fileKey, onToggleReviewed, scrollContainer, }: AutoMarkArgs) { @@ -167,143 +182,18 @@ function useAutoMarkOnScroll({ !autoMarkedRef.current ) { autoMarkedRef.current = true; - console.debug("[review] auto-mark reviewed:", filePath); - onToggleReviewed(filePath, true); + console.debug("[review] auto-mark reviewed:", fileKey); + onToggleReviewed(fileKey, true); } }, { threshold: 0, root }, ); observer.observe(sentinel); return () => observer.disconnect(); - }, [autoMarkOnScroll, filePath, isReviewed, isStale, onToggleReviewed, scrollContainer]); + }, [autoMarkOnScroll, fileKey, isReviewed, isStale, onToggleReviewed, scrollContainer]); return scrollSentinelRef; } -const iconBtn = "h-6 w-6 p-0 cursor-pointer opacity-60 hover:opacity-100"; -const iconBtnActive = "h-6 w-6 p-0 cursor-pointer opacity-100 bg-muted"; - -type FileDiffToolbarProps = { - diff: string; - filePath: string; - sessionId: string; - source: string; - wordWrap: boolean; - expandUnchanged: boolean; - onDiscard: () => void; - onOpenFile?: (filePath: string) => void; - onPreviewMarkdown?: (filePath: string) => void; - onToggleExpandUnchanged: () => void; - onToggleWordWrap: () => void; -}; - -function ToolbarIconBtn({ - onClick, - tooltip, - active, - children, - className, -}: { - onClick: () => void; - tooltip: string; - active?: boolean; - children: React.ReactNode; - className?: string; -}) { - return ( - - - - - {tooltip} - - ); -} - -function FileDiffToolbar(props: FileDiffToolbarProps) { - const { - diff, - filePath, - sessionId, - source, - wordWrap, - expandUnchanged, - onDiscard, - onOpenFile, - onPreviewMarkdown, - onToggleExpandUnchanged, - onToggleWordWrap, - } = props; - const [globalViewMode, setGlobalViewMode] = useGlobalViewMode(); - const handleCopyDiff = useCallback(() => { - navigator.clipboard.writeText(diff || ""); - }, [diff]); - const handleToggleViewMode = useCallback( - () => setGlobalViewMode(globalViewMode === "split" ? "unified" : "split"), - [globalViewMode, setGlobalViewMode], - ); - return ( -
- - - - - {expandUnchanged ? ( - - ) : ( - - )} - - - - - - {globalViewMode === "split" ? ( - - ) : ( - - )} - - {onPreviewMarkdown && isMarkdownPath(filePath) && ( - onPreviewMarkdown(filePath)} tooltip="Preview markdown"> - - - )} - {onOpenFile && ( - onOpenFile(filePath)} tooltip="Edit"> - - - )} - - {source === "uncommitted" && ( - - - - )} -
- ); -} - type FileDiffHeaderProps = { file: ReviewFile; isReviewed: boolean; @@ -411,11 +301,16 @@ function useCommentRunHandler(sessionId: string) { ); } -async function revertBlock(sessionId: string, filePath: string, info: RevertBlockInfo) { +async function revertBlock( + sessionId: string, + filePath: string, + info: RevertBlockInfo, + repo?: string, +) { const client = getWebSocketClient(); if (!client) return; try { - const response = await requestFileContent(client, sessionId, filePath); + const response = await requestFileContent(client, sessionId, filePath, repo); if (response.error) return; const currentContent = response.content; const hash = await calculateHash(currentContent); @@ -429,6 +324,7 @@ async function revertBlock(sessionId: string, filePath: string, info: RevertBloc path: filePath, diff: patch, originalHash: hash, + repo, }); } catch (err) { console.error("Failed to revert change block:", err); @@ -490,6 +386,7 @@ function renderDiffContent(opts: { hideHeader expandUnchanged={expandUnchanged} onToggleExpandUnchanged={onToggleExpandUnchanged} + repo={file.repository_name} /> {file.diff_skip_reason === "truncated" && (
@@ -508,6 +405,7 @@ function renderDiffContent(opts: { function FileDiffSection({ file, + fileKey, isReviewed, isStale, sessionId, @@ -536,27 +434,31 @@ function FileDiffSection({ // Force load when visible via intersection observer, or forceLoad is true const shouldRenderContent = isVisible || !!forceLoad; useScrollIntoViewOnSelect(isSelected, sectionRef, setCollapsed); + // Auto-mark sends the composite key (matches the dialog's reviewed-set + // shape) so cross-repo same-named files don't all get marked when one + // scrolls past. const scrollSentinelRef = useAutoMarkOnScroll({ autoMarkOnScroll, isReviewed, isStale, - filePath: file.path, + fileKey, onToggleReviewed, scrollContainer, }); const handleCheckboxChange = useCallback( (checked: boolean | "indeterminate") => { - onToggleReviewed(file.path, checked === true); + onToggleReviewed(fileKey, checked === true); }, - [file.path, onToggleReviewed], + [fileKey, onToggleReviewed], ); const handleDiscard = useCallback(() => { - onDiscard(file.path); - }, [file.path, onDiscard]); + onDiscard(fileKey); + }, [fileKey, onDiscard]); const handleRevertBlock = useCallback( - (filePath: string, info: RevertBlockInfo) => revertBlock(sessionId, filePath, info), - [sessionId], + (filePath: string, info: RevertBlockInfo) => + revertBlock(sessionId, filePath, info, file.repository_name), + [sessionId, file.repository_name], ); const handleCommentRun = useCommentRunHandler(sessionId); diff --git a/apps/web/components/review/review-diff-toolbar.tsx b/apps/web/components/review/review-diff-toolbar.tsx new file mode 100644 index 0000000000..54fc50fde8 --- /dev/null +++ b/apps/web/components/review/review-diff-toolbar.tsx @@ -0,0 +1,148 @@ +"use client"; + +import { useCallback } from "react"; +import { + IconArrowBackUp, + IconCopy, + IconEye, + IconFold, + IconFoldDown, + IconLayoutColumns, + IconLayoutRows, + IconPencil, + IconTextWrap, +} from "@tabler/icons-react"; +import { Button } from "@kandev/ui/button"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; +import { FileActionsDropdown } from "@/components/editors/file-actions-dropdown"; +import { useGlobalViewMode } from "@/hooks/use-global-view-mode"; + +const iconBtn = "h-6 w-6 p-0 cursor-pointer opacity-60 hover:opacity-100"; +const iconBtnActive = "h-6 w-6 p-0 cursor-pointer opacity-100 bg-muted"; + +function isMarkdownPath(filePath: string): boolean { + const ext = filePath.split(".").pop()?.toLowerCase(); + return ext === "md" || ext === "mdx"; +} + +export type FileDiffToolbarProps = { + diff: string; + filePath: string; + sessionId: string; + source: string; + wordWrap: boolean; + expandUnchanged: boolean; + onDiscard: () => void; + onOpenFile?: (filePath: string) => void; + onPreviewMarkdown?: (filePath: string) => void; + onToggleExpandUnchanged: () => void; + onToggleWordWrap: () => void; +}; + +function ToolbarIconBtn({ + onClick, + tooltip, + active, + children, + className, +}: { + onClick: () => void; + tooltip: string; + active?: boolean; + children: React.ReactNode; + className?: string; +}) { + return ( + + + + + {tooltip} + + ); +} + +export function FileDiffToolbar(props: FileDiffToolbarProps) { + const { + diff, + filePath, + sessionId, + source, + wordWrap, + expandUnchanged, + onDiscard, + onOpenFile, + onPreviewMarkdown, + onToggleExpandUnchanged, + onToggleWordWrap, + } = props; + const [globalViewMode, setGlobalViewMode] = useGlobalViewMode(); + const handleCopyDiff = useCallback(() => { + navigator.clipboard.writeText(diff || ""); + }, [diff]); + const handleToggleViewMode = useCallback( + () => setGlobalViewMode(globalViewMode === "split" ? "unified" : "split"), + [globalViewMode, setGlobalViewMode], + ); + return ( +
+ + + + + {expandUnchanged ? ( + + ) : ( + + )} + + + + + + {globalViewMode === "split" ? ( + + ) : ( + + )} + + {onPreviewMarkdown && isMarkdownPath(filePath) && ( + onPreviewMarkdown(filePath)} tooltip="Preview markdown"> + + + )} + {onOpenFile && ( + onOpenFile(filePath)} tooltip="Edit"> + + + )} + + {source === "uncommitted" && ( + + + + )} +
+ ); +} diff --git a/apps/web/components/review/review-file-tree.tsx b/apps/web/components/review/review-file-tree.tsx index 196b246156..914d49fc0d 100644 --- a/apps/web/components/review/review-file-tree.tsx +++ b/apps/web/components/review/review-file-tree.tsx @@ -13,7 +13,7 @@ import { Checkbox } from "@kandev/ui/checkbox"; import { cn } from "@kandev/ui/lib/utils"; import { FileIcon } from "@/components/ui/file-icon"; import type { ReviewFile, FileTreeNode } from "./types"; -import { buildFileTree } from "./types"; +import { buildFileTree, reviewFileKey } from "./types"; type ReviewFileTreeProps = { files: ReviewFile[]; @@ -107,10 +107,13 @@ function FileNode({ depth, }: Omit & { node: FileTreeNode }) { const file = node.file!; - const isReviewed = reviewedFiles.has(file.path); - const isStale = staleFiles.has(file.path); - const commentCount = commentCountByFile[file.path] ?? 0; - const isSelected = selectedFile === file.path; + // Composite key from reviewFileKey() so two same-name files in + // different repos don't share reviewed/stale/comment-count slots. + const key = reviewFileKey(file); + const isReviewed = reviewedFiles.has(key); + const isStale = staleFiles.has(key); + const commentCount = commentCountByFile[key] ?? 0; + const isSelected = selectedFile === key; return (
onSelectFile(file.path)} + onClick={() => onSelectFile(key)} > { - onToggleReviewed(file.path, checked === true); + onToggleReviewed(key, checked === true); }} onClick={(e) => e.stopPropagation()} className="h-3.5 w-3.5" @@ -165,11 +168,16 @@ function TreeNode({ }; if (node.isDir) { + // Repo roots get a stronger label so the user sees the multi-repo grouping. + const fileCount = node.isRepoRoot ? countLeafFiles(node) : 0; return ( -
+
{expanded && node.children && (
@@ -193,3 +211,12 @@ function TreeNode({ return ; } + +/** Counts leaf (file) nodes anywhere under the given node. Used for the + * "N files" badge on multi-repo repo-root headers. */ +function countLeafFiles(node: FileTreeNode): number { + if (!node.isDir) return 1; + let total = 0; + for (const child of node.children ?? []) total += countLeafFiles(child); + return total; +} diff --git a/apps/web/components/review/types.multi-repo.test.ts b/apps/web/components/review/types.multi-repo.test.ts new file mode 100644 index 0000000000..e1aa9362b8 --- /dev/null +++ b/apps/web/components/review/types.multi-repo.test.ts @@ -0,0 +1,142 @@ +import { describe, it, expect } from "vitest"; +import { buildFileTree, reviewFileKey, splitReviewFileKey, type ReviewFile } from "./types"; + +const SEP = "\u0000"; + +const APP_PATH = "src/app.tsx"; + +function file(overrides: Partial): ReviewFile { + return { + path: APP_PATH, + diff: "", + status: "modified", + additions: 1, + deletions: 0, + staged: false, + source: "uncommitted", + ...overrides, + }; +} + +describe("buildFileTree — multi-repo", () => { + it("falls back to flat tree when files have no repository_name", () => { + const tree = buildFileTree([file({ path: "src/a.ts" }), file({ path: "src/b.ts" })]); + expect(tree[0].isRepoRoot).toBeUndefined(); + expect(tree[0].name).toContain("src"); + }); + + it("falls back to flat tree when only one repository is present", () => { + const tree = buildFileTree([ + file({ path: "src/a.ts", repository_name: "frontend", repository_id: "f" }), + file({ path: "src/b.ts", repository_name: "frontend", repository_id: "f" }), + ]); + expect(tree[0].isRepoRoot).toBeUndefined(); + }); + + it("groups by repo when 2+ distinct repositories are present", () => { + const tree = buildFileTree([ + file({ path: APP_PATH, repository_name: "frontend", repository_id: "f" }), + file({ path: "src/api.ts", repository_name: "frontend", repository_id: "f" }), + file({ path: "handlers/task.go", repository_name: "backend", repository_id: "b" }), + ]); + expect(tree).toHaveLength(2); + expect(tree.map((n) => n.name).sort()).toEqual(["backend", "frontend"]); + for (const root of tree) { + expect(root.isRepoRoot).toBe(true); + expect(root.repositoryId).toBeDefined(); + } + }); + + it("repo roots are not collapsed even when they have a single child", () => { + const tree = buildFileTree([ + file({ path: "lonely.ts", repository_name: "shared", repository_id: "s" }), + file({ path: "src/x.ts", repository_name: "main", repository_id: "m" }), + ]); + const shared = tree.find((n) => n.name === "shared"); + expect(shared).toBeDefined(); + expect(shared?.isRepoRoot).toBe(true); + expect(shared?.children).toHaveLength(1); + expect(shared?.children?.[0].name).toBe("lonely.ts"); + }); + + it("preserves file paths inside each repo (no leakage between repos)", () => { + const tree = buildFileTree([ + file({ path: "README.md", repository_name: "frontend", repository_id: "f" }), + file({ path: "README.md", repository_name: "backend", repository_id: "b" }), + ]); + const frontend = tree.find((n) => n.name === "frontend"); + const backend = tree.find((n) => n.name === "backend"); + expect(frontend?.children?.[0].name).toBe("README.md"); + expect(backend?.children?.[0].name).toBe("README.md"); + // File node back-refs point to the right repo. + expect(frontend?.children?.[0].file?.repository_id).toBe("f"); + expect(backend?.children?.[0].file?.repository_id).toBe("b"); + }); +}); + +// reviewFileKey + splitReviewFileKey are the dedup primitive for the +// multi-repo review state. They have to round-trip exactly, including +// path values that contain the path-separator characters most likely to +// appear in real user input. The NUL byte (FILE_KEY_SEP) is the one +// character that can never legitimately appear in a path or repo name on +// any supported filesystem, which is why it's the separator. +describe("reviewFileKey", () => { + it("returns the bare path when no repository_name is set", () => { + expect(reviewFileKey({ path: "README.md" })).toBe("README.md"); + expect(reviewFileKey({ path: "src/index.ts", repository_name: "" })).toBe("src/index.ts"); + }); + + it("joins repository_name and path with the NUL separator", () => { + expect(reviewFileKey({ path: "README.md", repository_name: "frontend" })).toBe( + `frontend${SEP}README.md`, + ); + }); + + it("preserves slashes, dots, and spaces in the path", () => { + expect(reviewFileKey({ path: "src/sub dir/file.ts", repository_name: "repo" })).toBe( + `repo${SEP}src/sub dir/file.ts`, + ); + }); + + it("disambiguates same-named files in different repos", () => { + const a = reviewFileKey({ path: "README.md", repository_name: "frontend" }); + const b = reviewFileKey({ path: "README.md", repository_name: "backend" }); + expect(a).not.toBe(b); + }); +}); + +describe("splitReviewFileKey", () => { + it("returns empty repositoryName for legacy bare-path keys", () => { + expect(splitReviewFileKey("README.md")).toEqual({ repositoryName: "", path: "README.md" }); + }); + + it("splits a composite key on the NUL separator", () => { + expect(splitReviewFileKey(`frontend${SEP}${APP_PATH}`)).toEqual({ + repositoryName: "frontend", + path: APP_PATH, + }); + }); + + it("treats a key with empty repository_name as a legacy bare-path key", () => { + // reviewFileKey({path, repository_name: ""}) returns the bare path, + // so split should mirror that — no NUL in input means repo is "". + expect(splitReviewFileKey(APP_PATH)).toEqual({ repositoryName: "", path: APP_PATH }); + }); + + it("round-trips through reviewFileKey", () => { + const cases = [ + { path: "README.md" }, + { path: "src/index.ts", repository_name: "frontend" }, + { path: "deep/nested/path/file.go", repository_name: "backend" }, + { path: "with spaces/file.md", repository_name: "shared" }, + // Repo names can themselves contain dashes and slashes. + { path: "x.ts", repository_name: "owner/repo-name" }, + ]; + for (const original of cases) { + const key = reviewFileKey(original); + const round = splitReviewFileKey(key); + expect(round.path).toBe(original.path); + expect(round.repositoryName).toBe(original.repository_name ?? ""); + } + }); +}); diff --git a/apps/web/components/review/types.ts b/apps/web/components/review/types.ts index c3d4e3b4d9..25917a60fe 100644 --- a/apps/web/components/review/types.ts +++ b/apps/web/components/review/types.ts @@ -9,8 +9,37 @@ export type ReviewFile = { staged: boolean; source: "uncommitted" | "committed" | "pr"; diff_skip_reason?: "too_large" | "binary" | "truncated" | "budget_exceeded"; + /** + * Repository this file belongs to. Set on multi-repo task changes so the + * file tree groups files under per-repo top-level nodes. Optional for + * single-repo tasks; the tree falls back to flat path-only grouping. + */ + repository_id?: string; + /** Human-readable repo name for tree node labels (e.g. "frontend"). */ + repository_name?: string; }; +/** + * Composite per-file key used by the review dialog's in-memory state + * (reviewed set, stale set, file refs, selected file, comment counts) and + * by the persisted `useSessionFileReviews` rows. The NUL separator is + * impossible to embed in a real path or repository name, so the key is + * always uniquely splittable. Single-repo files (no `repository_name`) + * keep the legacy bare-path key for backwards compatibility with existing + * `session_file_reviews` rows. + */ +const FILE_KEY_SEP = "\u0000"; + +export function reviewFileKey(file: { path: string; repository_name?: string }): string { + return file.repository_name ? `${file.repository_name}${FILE_KEY_SEP}${file.path}` : file.path; +} + +export function splitReviewFileKey(key: string): { repositoryName: string; path: string } { + const sep = key.indexOf(FILE_KEY_SEP); + if (sep < 0) return { repositoryName: "", path: key }; + return { repositoryName: key.slice(0, sep), path: key.slice(sep + 1) }; +} + export function diffSkipReasonLabel(reason?: string): string { switch (reason) { case "too_large": @@ -32,6 +61,14 @@ export type FileTreeNode = { isDir: boolean; children?: FileTreeNode[]; file?: ReviewFile; + /** + * True when this node represents a repository root in a multi-repo file + * tree. Repo roots are pinned (never collapsed into their first child) so + * the user always sees the repo grouping at the top level. + */ + isRepoRoot?: boolean; + /** Repository id this node represents (only set when isRepoRoot is true). */ + repositoryId?: string; }; /** @@ -61,8 +98,54 @@ export const hashDiff = djb2Hash; /** * Build a hierarchical file tree from flat file paths. * Collapses single-child directories (e.g., `src/components` as one node if `src` has no other children). + * + * Multi-repo: when files carry repository_name and 2+ distinct repos are + * present, the tree gets a top-level repo node per repository so the user + * sees per-repo grouping. Repo roots are pinned (never collapsed) and the + * file paths inside them stay relative to the repo root. */ export function buildFileTree(files: ReviewFile[]): FileTreeNode[] { + const repoNames = new Set(); + for (const f of files) { + if (f.repository_name) repoNames.add(f.repository_name); + } + const isMultiRepo = repoNames.size > 1; + + if (isMultiRepo) return buildMultiRepoTree(files); + return buildFlatTree(files); +} + +function buildMultiRepoTree(files: ReviewFile[]): FileTreeNode[] { + // Group by repo first, then build a sub-tree for each. + const byRepo = new Map(); + for (const f of files) { + const name = f.repository_name ?? "(unspecified)"; + const id = f.repository_id ?? name; + const key = id; + let entry = byRepo.get(key); + if (!entry) { + entry = { name, id, files: [] }; + byRepo.set(key, entry); + } + entry.files.push(f); + } + const repoRoots: FileTreeNode[] = []; + for (const [, entry] of byRepo) { + const subtree = buildFlatTree(entry.files); + repoRoots.push({ + name: entry.name, + path: `__repo__:${entry.id}`, + isDir: true, + isRepoRoot: true, + repositoryId: entry.id, + children: subtree, + }); + } + // Stable alphabetical order by repo name. + return repoRoots.sort((a, b) => a.name.localeCompare(b.name)); +} + +function buildFlatTree(files: ReviewFile[]): FileTreeNode[] { const root: FileTreeNode = { name: "", path: "", isDir: true, children: [] }; for (const file of files) { @@ -92,13 +175,20 @@ export function buildFileTree(files: ReviewFile[]): FileTreeNode[] { } } - // Collapse single-child directories + // Collapse single-child directories. Repo roots are never collapsed — + // they encode user-meaningful grouping that survives even when the repo + // has only one changed file. function collapse(node: FileTreeNode): FileTreeNode { if (!node.isDir || !node.children) return node; node.children = node.children.map(collapse); - if (node.children.length === 1 && node.children[0].isDir && node.name !== "") { + if ( + node.children.length === 1 && + node.children[0].isDir && + node.name !== "" && + !node.isRepoRoot + ) { const child = node.children[0]; return { ...child, diff --git a/apps/web/components/task-create-dialog-create-mode-selectors.tsx b/apps/web/components/task-create-dialog-create-mode-selectors.tsx new file mode 100644 index 0000000000..05264e4a05 --- /dev/null +++ b/apps/web/components/task-create-dialog-create-mode-selectors.tsx @@ -0,0 +1,51 @@ +"use client"; + +import type { AgentProfileOption } from "@/lib/state/slices"; +import type { DialogFormState } from "@/components/task-create-dialog-types"; +import { CreateEditSelectors } from "@/components/task-create-dialog-form-body"; +import { AgentSelector, ExecutorProfileSelector } from "@/components/task-create-dialog-selectors"; +import { useAgentProfileOptions } from "@/components/task-create-dialog-options"; + +type CreateModeSelectorsProps = { + isTaskStarted: boolean; + agentProfileOptions: ReturnType; + executorProfileOptions: Array<{ + value: string; + label: string; + renderLabel?: () => React.ReactNode; + }>; + agentProfiles: AgentProfileOption[]; + agentProfilesLoading: boolean; + executorsLoading: boolean; + isCreatingSession: boolean; + fs: DialogFormState; + onAgentProfileChange: (v: string) => void; + onExecutorProfileChange: (v: string) => void; + workflowAgentLocked: boolean; +}; + +/** + * Create/edit-mode form body section: agent + executor profile selectors. + * Repo, branch and fresh-branch toggle live in the chip row above the + * description (RepoChipsRow). + */ +export function CreateModeSelectors(props: CreateModeSelectorsProps) { + return ( + + ); +} diff --git a/apps/web/components/task-create-dialog-effects.ts b/apps/web/components/task-create-dialog-effects.ts index 66845c7b84..033a5dea0f 100644 --- a/apps/web/components/task-create-dialog-effects.ts +++ b/apps/web/components/task-create-dialog-effects.ts @@ -1,14 +1,13 @@ "use client"; import { useEffect } from "react"; -import type { Repository, Executor, Branch } from "@/lib/types/http"; +import type { Repository, Executor } from "@/lib/types/http"; import type { AgentProfileOption } from "@/lib/state/slices"; import { DEFAULT_LOCAL_EXECUTOR_TYPE } from "@/lib/utils"; import { useToast } from "@/components/toast-provider"; import { discoverRepositoriesAction, getLocalRepositoryStatusAction, - listLocalRepositoryBranchesAction, } from "@/app/actions/workspaces"; import { getLocalStorage } from "@/lib/local-storage"; import { STORAGE_KEYS } from "@/lib/settings/constants"; @@ -19,7 +18,6 @@ import type { StoreSelections, TaskCreateEffectsArgs, } from "@/components/task-create-dialog-types"; -import { autoSelectBranch } from "@/components/task-create-dialog-helpers"; export function useWorkflowAgentProfileEffect( fs: DialogFormState, @@ -80,25 +78,28 @@ export function useRepositoryAutoSelectEffect( workspaceId: string | null, repositories: Repository[], ) { - const { repositoryId, selectedLocalRepo, useGitHubUrl, setRepositoryId } = fs; + // On open, ensure there's always at least one chip rendered: prefer the + // user's last-used repo (or the workspace's only repo) so the chip lands + // pre-filled, but fall back to an empty row so the picker is visible + // instead of just the "+" button. URL mode is excluded — that flow swaps + // the chip row for a URL input. + const { repositories: rows, useGitHubUrl, setRepositories } = fs; useEffect(() => { - if (!open || !workspaceId || repositoryId || selectedLocalRepo || useGitHubUrl) return; + if (!open || !workspaceId || useGitHubUrl) return; + if (rows.length > 0) return; const lastUsedRepoId = getLocalStorage(STORAGE_KEYS.LAST_REPOSITORY_ID, null); + let pickId: string | null = null; if (lastUsedRepoId && repositories.some((r: Repository) => r.id === lastUsedRepoId)) { - void Promise.resolve().then(() => setRepositoryId(lastUsedRepoId)); - return; + pickId = lastUsedRepoId; + } else if (repositories.length === 1) { + pickId = repositories[0].id; } - if (repositories.length === 1) - void Promise.resolve().then(() => setRepositoryId(repositories[0].id)); - }, [ - open, - repositories, - repositoryId, - selectedLocalRepo, - useGitHubUrl, - workspaceId, - setRepositoryId, - ]); + void Promise.resolve().then(() => + setRepositories([ + pickId ? { key: "row-0", repositoryId: pickId, branch: "" } : { key: "row-0", branch: "" }, + ]), + ); + }, [open, repositories, rows, useGitHubUrl, workspaceId, setRepositories]); } export function useDiscoverReposEffect( @@ -148,71 +149,31 @@ export function useDiscoverReposEffect( ]); } -export function useLocalBranchesEffect( - fs: DialogFormState, - open: boolean, - workspaceId: string | null, - toast: ReturnType["toast"], -) { - const { selectedLocalRepo, setLocalBranches, setLocalBranchesLoading, setCurrentLocalBranch } = - fs; - useEffect(() => { - if (!open || !workspaceId || !selectedLocalRepo) return; - const repoPath = selectedLocalRepo.path; - void Promise.resolve() - .then(() => setLocalBranchesLoading(true)) - .then(() => listLocalRepositoryBranchesAction(workspaceId, repoPath)) - .then((r) => { - setLocalBranches(r.branches); - setCurrentLocalBranch(r.current_branch ?? ""); - }) - .catch((e) => { - toast({ - title: "Failed to load branches", - description: e instanceof Error ? e.message : "Request failed", - variant: "error", - }); - setLocalBranches([]); - setCurrentLocalBranch(""); - }) - .finally(() => { - setLocalBranchesLoading(false); - }); - }, [ - open, - selectedLocalRepo, - toast, - workspaceId, - setLocalBranches, - setLocalBranchesLoading, - setCurrentLocalBranch, - ]); -} - -/** - * Resolves the absolute repo path for whichever repository the user has selected - * (workspace-attached or discovered) and fetches its currently-checked-out branch. - * Used by the disabled branch-selector placeholder so users see exactly which - * branch their local clone is on. - */ +// Per-row branch listing now lives in the chip itself via useBranches, so the +// old useLocalBranchesEffect is gone. +// +// useCurrentLocalBranchEffect still earns its keep — the fresh-branch +// consent flow needs to know which branch the on-disk clone is currently on, +// and that's only meaningful for a single-row local-executor task. For multi- +// repo tasks fresh-branch is hidden in the UI, so we only resolve a path +// when there's exactly one row. export function useCurrentLocalBranchEffect( fs: DialogFormState, open: boolean, workspaceId: string | null, repositories: Repository[], ) { - const { repositoryId, selectedLocalRepo, useGitHubUrl, setCurrentLocalBranch } = fs; + const { repositories: rows, useGitHubUrl, setCurrentLocalBranch } = fs; useEffect(() => { - if (!open || !workspaceId || useGitHubUrl) { + if (!open || !workspaceId || useGitHubUrl || rows.length !== 1) { setCurrentLocalBranch(""); return; } - let path = ""; - if (selectedLocalRepo) { - path = selectedLocalRepo.path; - } else if (repositoryId) { - const repo = repositories.find((r: Repository) => r.id === repositoryId); - if (repo?.local_path) path = repo.local_path; + const row = rows[0]; + let path = row.localPath ?? ""; + if (!path && row.repositoryId) { + const repo = repositories.find((r: Repository) => r.id === row.repositoryId); + path = repo?.local_path ?? ""; } if (!path) { setCurrentLocalBranch(""); @@ -229,15 +190,7 @@ export function useCurrentLocalBranchEffect( return () => { cancelled = true; }; - }, [ - open, - workspaceId, - useGitHubUrl, - repositoryId, - selectedLocalRepo, - repositories, - setCurrentLocalBranch, - ]); + }, [open, workspaceId, useGitHubUrl, rows, repositories, setCurrentLocalBranch]); } export function useDefaultSelectionsEffect( @@ -328,37 +281,57 @@ export function useDefaultSelectionsEffect( } } }, [executorProfileId, executors, setExecutorId]); + + // Multi-repo guard: when 2+ repos are selected, only worktree profiles can + // run the task (Docker / Sprites / standalone don't yet provision sibling + // repos under one task root). If the current profile is non-worktree, swap + // to a worktree profile — preferring the last-used worktree, otherwise the + // first one available. Single-repo selections leave the profile alone. + useEffect(() => { + if (!open || !executorProfileId || executors.length === 0) return; + const namedRepos = fs.repositories.filter((r) => r.repositoryId || r.localPath); + if (namedRepos.length <= 1) return; + const profileToType = new Map(); + const worktreeProfileIds: string[] = []; + for (const e of executors) { + for (const p of e.profiles ?? []) { + const type = p.executor_type ?? e.type; + profileToType.set(p.id, type); + if (type === "worktree") worktreeProfileIds.push(p.id); + } + } + if (worktreeProfileIds.length === 0) return; + if (profileToType.get(executorProfileId) === "worktree") return; + const lastId = getLocalStorage(STORAGE_KEYS.LAST_EXECUTOR_PROFILE_ID, null); + const pick = lastId && worktreeProfileIds.includes(lastId) ? lastId : worktreeProfileIds[0]; + void Promise.resolve().then(() => setExecutorProfileId(pick)); + }, [open, executorProfileId, executors, fs.repositories, setExecutorProfileId]); } -export function useBranchAutoSelectEffect(fs: DialogFormState, branches: Branch[]) { - const { repositoryId, branch, localBranches, githubBranches, useGitHubUrl, setBranch } = fs; +/** + * Auto-selects a sensible branch in the GitHub URL flow. Per-repo (workspace + * or discovered) branch auto-select happens inside RepoChip when a row's + * branches load — that keeps the per-row state confined to the chip. + */ +export function useBranchAutoSelectEffect(fs: DialogFormState) { + const { githubBranch, githubBranches, useGitHubUrl, setGitHubBranch, githubPrHeadBranch } = fs; useEffect(() => { - if (!repositoryId || branch) return; - autoSelectBranch(branches, setBranch); - }, [branch, branches, repositoryId, setBranch]); - useEffect(() => { - if (repositoryId || localBranches.length === 0 || branch) return; - autoSelectBranch(localBranches, setBranch); - }, [branch, localBranches, repositoryId, setBranch]); - const { githubPrHeadBranch } = fs; - useEffect(() => { - if (!useGitHubUrl || githubBranches.length === 0 || branch) return; - // If a PR URL was detected, prefer the PR's head branch. + if (!useGitHubUrl || githubBranches.length === 0 || githubBranch) return; if (githubPrHeadBranch) { const prBranch = githubBranches.find((b) => b.name === githubPrHeadBranch); if (prBranch) { - setBranch(prBranch.name); + setGitHubBranch(prBranch.name); return; } } - // GitHub URL branches are referenced by name only (no remote prefix). + // 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, githubPrHeadBranch]); + if (preferred) setGitHubBranch(preferred.name); + }, [githubBranch, githubBranches, useGitHubUrl, setGitHubBranch, githubPrHeadBranch]); } /** Parse a GitHub URL to extract owner, repo, and optional PR number. Returns null if invalid. */ @@ -468,14 +441,13 @@ export function useGitHubUrlBranchesEffect(fs: DialogFormState, open: boolean) { } export function useTaskCreateDialogEffects(fs: DialogFormState, args: TaskCreateEffectsArgs) { - const { open, workspaceId, workflowId, repositories, repositoriesLoading, branches } = args; + const { open, workspaceId, workflowId, repositories, repositoriesLoading } = args; const { agentProfiles, executors, workspaceDefaults, toast, workflows } = args; useWorkflowStepsEffect(fs, workflowId); useWorkflowAgentProfileEffect(fs, workflows, agentProfiles); useRepositoryAutoSelectEffect(fs, open, workspaceId, repositories); useDiscoverReposEffect(fs, open, workspaceId, repositoriesLoading, toast); - useBranchAutoSelectEffect(fs, branches); - useLocalBranchesEffect(fs, open, workspaceId, toast); + useBranchAutoSelectEffect(fs); useCurrentLocalBranchEffect(fs, open, workspaceId, repositories); useDefaultSelectionsEffect(fs, open, { agentProfiles, executors, workspaceDefaults }, workflows); useGitHubUrlBranchesEffect(fs, open); diff --git a/apps/web/components/task-create-dialog-footer.test.ts b/apps/web/components/task-create-dialog-footer.test.ts index 8bcc92a00f..69b4395f97 100644 --- a/apps/web/components/task-create-dialog-footer.test.ts +++ b/apps/web/components/task-create-dialog-footer.test.ts @@ -29,7 +29,7 @@ function makeProps( hasTitle: true, hasDescription: true, hasRepositorySelection: true, - branch: "main", + hasAllBranches: true, agentProfileId: "agent-1", workspaceId: "ws-1", effectiveWorkflowId: "wf-1", @@ -69,7 +69,9 @@ describe("computeDisabledReason (start-task)", () => { }); it("flags missing branch", () => { - expect(computeDisabledReason(makeProps({ branch: "" }), KIND_START)).toBe(REASON_BRANCH); + expect(computeDisabledReason(makeProps({ hasAllBranches: false }), KIND_START)).toBe( + REASON_BRANCH, + ); }); it("flags missing workspace in create mode", () => { @@ -157,7 +159,7 @@ describe("computeDisabledReason (default)", () => { isSessionMode: true, hasTitle: false, hasRepositorySelection: false, - branch: "", + hasAllBranches: false, }), KIND_DEFAULT, ), diff --git a/apps/web/components/task-create-dialog-footer.tsx b/apps/web/components/task-create-dialog-footer.tsx index a65a4967c3..2b78f929f8 100644 --- a/apps/web/components/task-create-dialog-footer.tsx +++ b/apps/web/components/task-create-dialog-footer.tsx @@ -216,7 +216,12 @@ export type TaskCreateDialogFooterProps = { hasTitle: boolean; hasDescription: boolean; hasRepositorySelection: boolean; - branch: string; + /** + * True when every selected repo has a base branch picked (and the URL flow's + * branch, if active). Was previously `branch: string` for the single-repo + * primary; now aggregated upstream because each row has its own branch. + */ + hasAllBranches: boolean; agentProfileId: string; workspaceId: string | null; effectiveWorkflowId: string | null; @@ -245,7 +250,7 @@ function computeBaseDisabled(props: TaskCreateDialogFooterProps) { props.isCreatingTask || !props.hasTitle || !props.hasRepositorySelection || - !props.branch || + !props.hasAllBranches || missingCtx ); } @@ -263,7 +268,7 @@ export const REASON_DESCRIPTION = "Add a session description"; function baseReason(props: TaskCreateDialogFooterProps): string | null { if (!props.hasTitle) return REASON_TITLE; if (!props.hasRepositorySelection) return REASON_REPO; - if (!props.branch) return REASON_BRANCH; + if (!props.hasAllBranches) return REASON_BRANCH; if (props.isCreateMode && !props.workspaceId) return REASON_WORKSPACE; if (props.isCreateMode && !props.effectiveWorkflowId) return REASON_WORKFLOW; return null; diff --git a/apps/web/components/task-create-dialog-form-body.tsx b/apps/web/components/task-create-dialog-form-body.tsx index aa03565f9d..98b596da0f 100644 --- a/apps/web/components/task-create-dialog-form-body.tsx +++ b/apps/web/components/task-create-dialog-form-body.tsx @@ -9,10 +9,6 @@ import { AgentLogo } from "@/components/agent-logo"; import type { DialogFormState } from "@/components/task-create-dialog-types"; import type { useKeyboardShortcutHandler } from "@/hooks/use-keyboard-shortcut"; import { TaskFormInputs } from "@/components/task-create-dialog-selectors"; -import { - FreshBranchToggle, - computeBranchPlaceholder, -} from "@/components/task-create-dialog-fresh-branch"; import type { JiraTicket } from "@/lib/types/jira"; import type { LinearIssue } from "@/lib/types/linear"; @@ -24,15 +20,6 @@ type SelectorOption = { type CreateEditSelectorsProps = { isTaskStarted: boolean; - hasRepositorySelection: boolean; - branchOptions: SelectorOption[]; - branch: string; - onBranchChange: (value: string) => void; - branchesLoading: boolean; - onRefreshBranches?: () => void; - branchesFetchedAt?: string; - branchesFetchError?: string; - localBranchesLoading: boolean; agentProfiles: AgentProfileOption[]; agentProfilesLoading: boolean; agentProfileOptions: SelectorOption[]; @@ -47,20 +34,6 @@ type CreateEditSelectorsProps = { executorProfileId: string; onExecutorProfileChange: (value: string) => void; executorsLoading: boolean; - BranchSelectorComponent: React.ComponentType<{ - options: SelectorOption[]; - value: string; - onValueChange: (value: string) => void; - disabled: boolean; - placeholder: string; - searchPlaceholder: string; - emptyMessage: string; - onRefresh?: () => void; - refreshing?: boolean; - fetchedAt?: string; - fetchError?: string; - loading?: boolean; - }>; AgentSelectorComponent: React.ComponentType<{ options: SelectorOption[]; value: string; @@ -77,13 +50,7 @@ type CreateEditSelectorsProps = { placeholder: string; triggerClassName?: string; }>; - isLocalExecutor: boolean; - useGitHubUrl: boolean; workflowAgentLocked: boolean; - freshBranchEnabled: boolean; - onToggleFreshBranch: (enabled: boolean) => void; - currentLocalBranch: string; - branchLocked?: boolean; }; type AgentColumnProps = Pick< @@ -135,97 +102,23 @@ function AgentColumn({ ); } -function computeBranchDisabled(args: { - branchLocked?: boolean; - lockedToCurrentBranch: boolean; - hasRepositorySelection: boolean; - branchesLoading: boolean; - localBranchesLoading: boolean; - optionCount: number; -}): boolean { - if (args.branchLocked) return true; - if (args.lockedToCurrentBranch) return true; - if (!args.hasRepositorySelection) return true; - if (args.branchesLoading || args.localBranchesLoading) return true; - return args.optionCount === 0; -} - export const CreateEditSelectors = memo(function CreateEditSelectors( props: CreateEditSelectorsProps, ) { if (props.isTaskStarted) return null; - const { - hasRepositorySelection, - branchOptions, - branch, - onBranchChange, - branchesLoading, - onRefreshBranches, - branchesFetchedAt, - branchesFetchError, - localBranchesLoading, executorProfileOptions, executorProfileId, onExecutorProfileChange, executorsLoading, - isLocalExecutor, - useGitHubUrl, - freshBranchEnabled, - onToggleFreshBranch, - currentLocalBranch, - BranchSelectorComponent, ExecutorProfileSelectorComponent, - branchLocked, } = props; - const isLocalWithoutGitHubUrl = isLocalExecutor && !useGitHubUrl; - // When `branchLocked` is true the caller pinned a specific branch (e.g. - // Improve Kandev) — suppress the local-executor "current branch" shortcut and - // the FreshBranchToggle so users can't drift away from the pinned value. - const lockedToCurrentBranch = !branchLocked && isLocalWithoutGitHubUrl && !freshBranchEnabled; - const branchPlaceholder = computeBranchPlaceholder({ - lockedToCurrentBranch, - currentLocalBranch, - hasRepositorySelection, - loading: branchesLoading || localBranchesLoading, - optionCount: branchOptions.length, - }); - const branchDisabled = computeBranchDisabled({ - branchLocked, - lockedToCurrentBranch, - hasRepositorySelection, - branchesLoading, - localBranchesLoading, - optionCount: branchOptions.length, - }); - // When the local executor locks to the current branch, ignore any branch - // value the user picked under a different executor — the placeholder - // should reflect the actual checked-out branch instead. - const branchValue = lockedToCurrentBranch ? "" : branch; + // Branch + repo selection (and the FreshBranchToggle, which is per-task + // branch strategy) live in the chip row above the description; this row + // carries only agent and executor profile selectors. return ( -
-
-
- -
- {isLocalWithoutGitHubUrl && !branchLocked && ( - - )} -
+
diff --git a/apps/web/components/task-create-dialog-handlers.ts b/apps/web/components/task-create-dialog-handlers.ts index 88ed8dff9a..4716c08efd 100644 --- a/apps/web/components/task-create-dialog-handlers.ts +++ b/apps/web/components/task-create-dialog-handlers.ts @@ -2,52 +2,59 @@ import { useCallback } from "react"; import type { Repository } from "@/lib/types/http"; +import type { DialogFormState, TaskRepoRow } from "@/components/task-create-dialog-types"; import { setLocalStorage } from "@/lib/local-storage"; import { STORAGE_KEYS } from "@/lib/settings/constants"; -import type { DialogFormState } from "@/components/task-create-dialog-types"; +/** + * Centralizes form-field change handlers for the task-create dialog. + * + * The dialog stores all repos in a single `fs.repositories` list (no + * "primary vs extras" split), so per-row handlers are uniform: changing + * a repo on row N is the same op whether N==0 or N==5. + * + * Fresh-branch (local-executor opt-in: discard local changes and start on + * a new branch) is a separate concern that lives alongside. + */ function clearFreshBranch(fs: DialogFormState) { fs.setFreshBranchEnabled(false); fs.setCurrentLocalBranch(""); } function useRepositoryHandlers(fs: DialogFormState, repositories: Repository[]) { - const handleSelectLocalRepository = useCallback( - (path: string) => { - fs.setDiscoveredRepoPath(path); - fs.setSelectedLocalRepo(fs.discoveredRepositories.find((r) => r.path === path) ?? null); - fs.setRepositoryId(""); - fs.setBranch(""); - fs.setLocalBranches([]); + /** + * Resolves a picker value into the right shape for a row: + * - If it matches a workspace repo id → `{ repositoryId, localPath: undefined }`. + * - Otherwise treat as a discovered on-machine path → `{ localPath, repositoryId: undefined }`. + * The branch is reset because the previous branch may not exist on the new repo. + */ + const handleRowRepositoryChange = useCallback( + (key: string, value: string) => { + const isWorkspaceRepo = repositories.some((r: Repository) => r.id === value); + const patch: Partial = isWorkspaceRepo + ? { repositoryId: value, localPath: undefined, branch: "" } + : { repositoryId: undefined, localPath: value, branch: "" }; + fs.updateRepository(key, patch); + if (isWorkspaceRepo) setLocalStorage(STORAGE_KEYS.LAST_REPOSITORY_ID, value); + // Switching the repo invalidates whatever local-status the fresh-branch + // panel had cached. clearFreshBranch(fs); }, + [repositories, fs], + ); + + const handleRowBranchChange = useCallback( + (key: string, value: string) => { + fs.updateRepository(key, { branch: value }); + setLocalStorage(STORAGE_KEYS.LAST_BRANCH, value); + }, [fs], ); - const handleRepositoryChange = useCallback( - (value: string) => { - if (repositories.find((r: Repository) => r.id === value)) { - fs.setRepositoryId(value); - setLocalStorage(STORAGE_KEYS.LAST_REPOSITORY_ID, value); - fs.setDiscoveredRepoPath(""); - fs.setSelectedLocalRepo(null); - fs.setLocalBranches([]); - fs.setBranch(""); - fs.setUseGitHubUrl(false); - fs.setGitHubUrl(""); - fs.setGitHubBranches([]); - clearFreshBranch(fs); - return; - } - handleSelectLocalRepository(value); - }, - [repositories, fs, handleSelectLocalRepository], - ); - - return { handleRepositoryChange, handleSelectLocalRepository }; + return { handleRowRepositoryChange, handleRowBranchChange }; } -function useBranchAndProfileHandlers(fs: DialogFormState) { +function useProfileAndNameHandlers(fs: DialogFormState) { const handleAgentProfileChange = useCallback( (value: string) => { fs.setAgentProfileId(value); @@ -69,9 +76,9 @@ function useBranchAndProfileHandlers(fs: DialogFormState) { }, [fs], ); - const handleBranchChange = useCallback( + const handleGitHubBranchChange = useCallback( (value: string) => { - fs.setBranch(value); + fs.setGitHubBranch(value); setLocalStorage(STORAGE_KEYS.LAST_BRANCH, value); }, [fs], @@ -84,37 +91,36 @@ function useBranchAndProfileHandlers(fs: DialogFormState) { handleAgentProfileChange, handleExecutorProfileChange, handleTaskNameChange, - handleBranchChange, + handleGitHubBranchChange, handleWorkflowChange, }; } function useGitHubAndFreshBranchHandlers(fs: DialogFormState) { + /** + * Toggles between "repo chips" mode and "GitHub URL" mode. URL mode + * replaces the chip row with a single URL input; flipping back restores + * the chip flow with whatever rows were already there. + */ const handleToggleGitHubUrl = useCallback(() => { const next = !fs.useGitHubUrl; fs.setUseGitHubUrl(next); - if (next) { - fs.setRepositoryId(""); - fs.setDiscoveredRepoPath(""); - fs.setSelectedLocalRepo(null); - fs.setLocalBranches([]); - } else { + if (!next) { fs.setGitHubUrl(""); fs.setGitHubBranches([]); fs.setGitHubUrlError(null); fs.setGitHubPrHeadBranch(null); } - fs.setBranch(""); + fs.setGitHubBranch(""); clearFreshBranch(fs); }, [fs]); const handleToggleFreshBranch = useCallback( (enabled: boolean) => { fs.setFreshBranchEnabled(enabled); - // Always clear the branch so we don't carry a value over from a - // different executor (e.g. "develop" picked under worktree) into the - // newly-enabled fresh-branch picker. - fs.setBranch(""); + // Clearing fs.repositories[].branch on toggle would force a re-pick from + // the per-row branch list; for simplicity leave whatever the user picked. + // The submit path re-validates anyway. }, [fs], ); @@ -122,7 +128,7 @@ function useGitHubAndFreshBranchHandlers(fs: DialogFormState) { const handleGitHubUrlChange = useCallback( (value: string) => { fs.setGitHubUrl(value); - fs.setBranch(""); + fs.setGitHubBranch(""); fs.setGitHubBranches([]); fs.setGitHubUrlError(null); fs.setGitHubPrHeadBranch(null); @@ -139,10 +145,10 @@ function useGitHubAndFreshBranchHandlers(fs: DialogFormState) { export function useDialogHandlers(fs: DialogFormState, repositories: Repository[]) { const repo = useRepositoryHandlers(fs, repositories); - const profile = useBranchAndProfileHandlers(fs); + const profile = useProfileAndNameHandlers(fs); const gh = useGitHubAndFreshBranchHandlers(fs); return { - handleRepositoryChange: repo.handleRepositoryChange, + ...repo, ...profile, ...gh, }; diff --git a/apps/web/components/task-create-dialog-header.tsx b/apps/web/components/task-create-dialog-header.tsx index 210750e4ec..36f02d777d 100644 --- a/apps/web/components/task-create-dialog-header.tsx +++ b/apps/web/components/task-create-dialog-header.tsx @@ -1,165 +1,28 @@ "use client"; import { DialogTitle } from "@kandev/ui/dialog"; -import { RepositorySelector, InlineTaskName } from "@/components/task-create-dialog-selectors"; -import type { useRepositoryOptions } from "@/components/task-create-dialog-options"; - -function getRepositoryPlaceholder( - workspaceId: string | null, - repositoriesLoading: boolean, - discoverReposLoading: boolean, -) { - if (!workspaceId) return "Select workspace first"; - if (repositoriesLoading || discoverReposLoading) return "Loading..."; - return "Select repository"; -} export type DialogHeaderContentProps = { isCreateMode: boolean; isEditMode: boolean; - isTaskStarted: boolean; sessionRepoName?: string; initialTitle?: string; - taskName: string; - repositoryId: string; - discoveredRepoPath: string; - workspaceId: string | null; - repositoriesLoading: boolean; - discoverReposLoading: boolean; - headerRepositoryOptions: ReturnType["headerRepositoryOptions"]; - onRepositoryChange: (v: string) => void; - onTaskNameChange: (v: string) => void; - useGitHubUrl: boolean; - githubUrl: string; - githubUrlError: string | null; - onToggleGitHubUrl: () => void; - onGitHubUrlChange: (v: string) => void; - repositoryLocked?: boolean; }; -type RepoSourceInputProps = Pick< - DialogHeaderContentProps, - | "useGitHubUrl" - | "githubUrl" - | "githubUrlError" - | "onGitHubUrlChange" - | "isTaskStarted" - | "headerRepositoryOptions" - | "repositoryId" - | "discoveredRepoPath" - | "onRepositoryChange" - | "workspaceId" - | "repositoriesLoading" - | "discoverReposLoading" - | "repositoryLocked" ->; - -function GhUrlInput({ - githubUrl, - githubUrlError, - onGitHubUrlChange, - isTaskStarted, -}: Pick< - RepoSourceInputProps, - "githubUrl" | "githubUrlError" | "onGitHubUrlChange" | "isTaskStarted" ->): React.ReactNode { - 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 && ( - - )} -
- ); -} - -function resolveSelectorPlaceholder(p: RepoSourceInputProps): string { - if (p.repositoryLocked && !(p.repositoryId || p.discoveredRepoPath)) { - return "Preparing kandev repository..."; - } - return getRepositoryPlaceholder(p.workspaceId, p.repositoriesLoading, p.discoverReposLoading); -} - -function RepoSourceInput(p: RepoSourceInputProps): React.ReactNode { - // When the dialog locks the repository (e.g. Improve Kandev pinning kdlbs/kandev), - // never expose the editable GitHub URL input — even if useGitHubUrl is on. - if (p.useGitHubUrl && !p.repositoryLocked) return ; - const isDisabled = - p.repositoryLocked || - p.isTaskStarted || - !p.workspaceId || - p.repositoriesLoading || - p.discoverReposLoading; - const emptyMessage = - p.repositoriesLoading || p.discoverReposLoading - ? "Loading repositories..." - : "No repositories found."; - return ( - - ); -} - -export function DialogHeaderContent(props: DialogHeaderContentProps) { - const { isCreateMode, isEditMode, isTaskStarted, sessionRepoName, initialTitle } = props; - const { taskName, onTaskNameChange, useGitHubUrl, onToggleGitHubUrl, repositoryLocked } = props; - +/** + * Header for the task-create dialog. In create/edit mode the header is + * intentionally minimal — the dialog itself signals "create" and the + * repo + branch chips and task-name input live in the body. Session mode + * shows a breadcrumb (repo / task / new session). + */ +export function DialogHeaderContent({ + isCreateMode, + isEditMode, + sessionRepoName, + initialTitle, +}: DialogHeaderContentProps) { if (isCreateMode || isEditMode) { - return ( - <> - -
- - / - -
-
- {!isTaskStarted && !repositoryLocked && ( -
- -
- )} - - ); + return {isEditMode ? "Edit task" : "New task"}; } return ( diff --git a/apps/web/components/task-create-dialog-helpers.multi-repo.test.ts b/apps/web/components/task-create-dialog-helpers.multi-repo.test.ts new file mode 100644 index 0000000000..95321d64fe --- /dev/null +++ b/apps/web/components/task-create-dialog-helpers.multi-repo.test.ts @@ -0,0 +1,82 @@ +import { describe, it, expect } from "vitest"; +import { buildRepositoriesPayload } from "./task-create-dialog-helpers"; + +describe("buildRepositoriesPayload — unified rows", () => { + it("maps each row in order, dropping empty ones silently", () => { + const payload = buildRepositoriesPayload({ + useGitHubUrl: false, + githubUrl: "", + githubBranch: "", + githubPrHeadBranch: null, + repositories: [ + { key: "r0", repositoryId: "repo-front", branch: "main" }, + { key: "r1", repositoryId: "repo-back", branch: "develop" }, + { key: "r2", branch: "" }, // no repo picked yet — dropped + { key: "r3", repositoryId: "repo-shared", branch: "" }, + ], + discoveredRepositories: [], + }); + expect(payload).toEqual([ + { repository_id: "repo-front", base_branch: "main" }, + { repository_id: "repo-back", base_branch: "develop" }, + { repository_id: "repo-shared", base_branch: undefined }, + ]); + }); + + it("emits local_path + default_branch for discovered (on-machine) rows", () => { + const payload = buildRepositoriesPayload({ + useGitHubUrl: false, + githubUrl: "", + githubBranch: "", + githubPrHeadBranch: null, + repositories: [ + { key: "r0", localPath: "/home/me/projects/local-project", branch: "trunk" }, + { key: "r1", repositoryId: "repo-back", branch: "main" }, + ], + discoveredRepositories: [ + { path: "/home/me/projects/local-project", default_branch: "trunk" }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ] as any, + }); + expect(payload).toEqual([ + { + repository_id: "", + base_branch: "trunk", + local_path: "/home/me/projects/local-project", + default_branch: "trunk", + }, + { repository_id: "repo-back", base_branch: "main" }, + ]); + }); + + it("URL mode produces a single github_url entry and ignores the rows", () => { + const payload = buildRepositoriesPayload({ + useGitHubUrl: true, + githubUrl: "github.com/owner/repo", + githubBranch: "feature-x", + githubPrHeadBranch: null, + repositories: [{ key: "r0", repositoryId: "ignored", branch: "ignored" }], + discoveredRepositories: [], + }); + expect(payload).toEqual([ + { + repository_id: "", + base_branch: "feature-x", + checkout_branch: undefined, + github_url: "github.com/owner/repo", + }, + ]); + }); + + it("single-row workspace repo: payload mirrors the row", () => { + const payload = buildRepositoriesPayload({ + useGitHubUrl: false, + githubUrl: "", + githubBranch: "", + githubPrHeadBranch: null, + repositories: [{ key: "r0", repositoryId: "repo-only", branch: "main" }], + discoveredRepositories: [], + }); + expect(payload).toEqual([{ repository_id: "repo-only", base_branch: "main" }]); + }); +}); diff --git a/apps/web/components/task-create-dialog-helpers.ts b/apps/web/components/task-create-dialog-helpers.ts index f274d3fbde..51cf63fd97 100644 --- a/apps/web/components/task-create-dialog-helpers.ts +++ b/apps/web/components/task-create-dialog-helpers.ts @@ -2,7 +2,7 @@ import type { useRouter } from "next/navigation"; import type { Task, Branch, LocalRepository } from "@/lib/types/http"; import type { AgentProfileOption } from "@/lib/state/slices"; import type { AppState } from "@/lib/state/store"; -import type { StepType } from "@/components/task-create-dialog-types"; +import type { StepType, TaskRepoRow } from "@/components/task-create-dialog-types"; import { selectPreferredBranch } from "@/lib/utils"; import { getLocalStorage } from "@/lib/local-storage"; import { STORAGE_KEYS } from "@/lib/settings/constants"; @@ -138,35 +138,51 @@ export function validateCreateInputs(inputs: { trimmedTitle: string; workspaceId: string | null; effectiveWorkflowId: string | null; - repositoryId: string; - selectedLocalRepo: LocalRepository | null; + /** Unified repos list. The form is valid if any row has a repo set OR URL mode is filled. */ + repositories: TaskRepoRow[]; githubUrl?: string; agentProfileId: string; }): boolean { + const hasRepo = + inputs.repositories.some((r) => r.repositoryId || r.localPath) || + Boolean(inputs.githubUrl?.trim()); return Boolean( inputs.trimmedTitle && inputs.workspaceId && inputs.effectiveWorkflowId && inputs.agentProfileId && - (inputs.repositoryId || inputs.selectedLocalRepo || inputs.githubUrl?.trim()), + hasRepo, ); } -/** Builds the repositories payload for task creation from the current form state. */ +/** + * Builds the repositories payload for task creation from the unified list. + * + * - URL mode produces a single entry with `github_url`. + * - Otherwise each row maps to either a workspace `repository_id` or a + * discovered `local_path`. Empty rows are dropped silently so a user + * can leave an unfinished chip without blocking submit; duplicate + * detection happens on the backend. + */ export function buildRepositoriesPayload(opts: { useGitHubUrl: boolean; githubUrl: string; - branch: string; + githubBranch: string; githubPrHeadBranch: string | null; - repositoryId: string; - selectedLocalRepo: LocalRepository | null; + repositories: TaskRepoRow[]; + /** Used to look up `default_branch` for `localPath` rows. */ + discoveredRepositories: LocalRepository[]; + /** + * Optional fresh-branch metadata. The UI gates this to single-row + local + * executor; when present we apply it to every row (which is at most one). + */ freshBranch?: { confirmDiscard: boolean; consentedDirtyFiles: string[] }; }): NonNullable { if (opts.useGitHubUrl && opts.githubUrl) { return [ { repository_id: "", - base_branch: opts.branch || undefined, + base_branch: opts.githubBranch || undefined, checkout_branch: opts.githubPrHeadBranch || undefined, github_url: opts.githubUrl.trim(), }, @@ -179,26 +195,23 @@ export function buildRepositoriesPayload(opts: { consented_dirty_files: opts.freshBranch.consentedDirtyFiles, } : {}; - if (opts.repositoryId) { - return [ - { - repository_id: opts.repositoryId, - base_branch: opts.branch || undefined, - checkout_branch: opts.githubPrHeadBranch || undefined, - ...fresh, - }, - ]; - } - if (opts.selectedLocalRepo) { - return [ - { + return opts.repositories + .filter((row) => row.repositoryId || row.localPath) + .map((row) => { + if (row.repositoryId) { + return { + repository_id: row.repositoryId, + base_branch: row.branch || undefined, + ...fresh, + }; + } + const local = opts.discoveredRepositories.find((d) => d.path === row.localPath); + return { repository_id: "", - base_branch: opts.branch || undefined, - local_path: opts.selectedLocalRepo.path, - default_branch: opts.selectedLocalRepo.default_branch || undefined, + base_branch: row.branch || undefined, + local_path: row.localPath, + default_branch: local?.default_branch || undefined, ...fresh, - }, - ]; - } - return []; + }; + }); } diff --git a/apps/web/components/task-create-dialog-locked-fields.ts b/apps/web/components/task-create-dialog-locked-fields.ts index 5c305b1439..737ffd2434 100644 --- a/apps/web/components/task-create-dialog-locked-fields.ts +++ b/apps/web/components/task-create-dialog-locked-fields.ts @@ -8,16 +8,15 @@ import type { type LockedFieldFormState = Pick< DialogFormState, - | "selectedWorkflowId" - | "setSelectedWorkflowId" - | "repositoryId" - | "setRepositoryId" - | "branch" - | "setBranch" + "selectedWorkflowId" | "setSelectedWorkflowId" | "repositories" | "setRepositories" >; -// Pushes late-arriving locked field values into form state when async feature -// wrappers resolve them after the dialog is already open. +/** + * Pushes late-arriving locked field values into form state when async feature + * wrappers resolve them after the dialog is already open. Multi-repo: locks + * are expressed as the first chip in `fs.repositories[]`; the dialog's + * single-repo locking flow (Improve Kandev) overwrites `repositories[0]`. + */ export function useLockedFieldSync( open: boolean, workflowId: string | null, @@ -31,12 +30,10 @@ export function useLockedFieldSync( if (workflowId && workflowId !== fs.selectedWorkflowId) { fs.setSelectedWorkflowId(workflowId); } - if (repoId && repoId !== fs.repositoryId) { - fs.setRepositoryId(repoId); - } - if (branch && branch !== fs.branch) { - fs.setBranch(branch); - } + if (!repoId) return; + const current = fs.repositories[0]; + if (current?.repositoryId === repoId && current?.branch === (branch ?? "")) return; + fs.setRepositories([{ key: "row-0", repositoryId: repoId, branch: branch ?? "" }]); // eslint-disable-next-line react-hooks/exhaustive-deps }, [open, workflowId, repoId, branch]); } diff --git a/apps/web/components/task-create-dialog-options.tsx b/apps/web/components/task-create-dialog-options.tsx index 40713c1e45..4650952699 100644 --- a/apps/web/components/task-create-dialog-options.tsx +++ b/apps/web/components/task-create-dialog-options.tsx @@ -21,6 +21,8 @@ type OptionItem = { value: string; label: string; renderLabel: () => React.ReactNode; + disabled?: boolean; + disabledReason?: string; }; export function useRepositoryOptions( @@ -206,17 +208,31 @@ export type ExecutorProfileOptionItem = OptionItem & { executorName?: string; }; +export type ExecutorProfileOptionsConfig = { + /** + * Returns a tooltip string when the given profile should render disabled. + * Used by the kanban task-create dialog to gate non-worktree executors + * (only worktree-based execution is currently supported there). + */ + disabledReasonFor?: (profile: ExecutorProfile) => string | null; +}; + export function useExecutorProfileOptions( allProfiles: ExecutorProfile[], + config?: ExecutorProfileOptionsConfig, ): ExecutorProfileOptionItem[] { + const disabledReasonFor = config?.disabledReasonFor; return useMemo(() => { return allProfiles.map((profile) => { const Icon = getExecutorIcon(profile.executor_type ?? "local"); + const disabledReason = disabledReasonFor?.(profile) ?? null; return { value: profile.id, label: profile.name, executorType: profile.executor_type, executorName: profile.executor_name, + disabled: !!disabledReason, + disabledReason: disabledReason ?? undefined, renderLabel: () => ( @@ -232,5 +248,5 @@ export function useExecutorProfileOptions( ), }; }); - }, [allProfiles]); + }, [allProfiles, disabledReasonFor]); } diff --git a/apps/web/components/task-create-dialog-repo-chips.test.tsx b/apps/web/components/task-create-dialog-repo-chips.test.tsx new file mode 100644 index 0000000000..8b9ad79adf --- /dev/null +++ b/apps/web/components/task-create-dialog-repo-chips.test.tsx @@ -0,0 +1,316 @@ +import { describe, it, expect, vi, afterEach } from "vitest"; +import { render, screen, fireEvent, cleanup } from "@testing-library/react"; +import type { Branch, Repository } from "@/lib/types/http"; +import type { DialogFormState, TaskRepoRow } from "./task-create-dialog-types"; + +// One mocked branches hook now — id-based and path-based rows go through +// the same loader. Tests can override the return value when they need +// branch-specific assertions; the mockBySource handle lets a test prove +// which kind of source the chip passed in. +const lastBranchSource = vi.hoisted((): { value: unknown } => ({ value: null })); +const mockBranches = vi.hoisted((): { value: { branches: Branch[]; isLoading: boolean } } => ({ + value: { branches: [], isLoading: false }, +})); + +vi.mock("@/hooks/domains/workspace/use-repository-branches", () => ({ + useBranches: (source: unknown) => { + lastBranchSource.value = source; + return mockBranches.value; + }, +})); + +import { RepoChipsRow } from "./task-create-dialog-repo-chips"; + +afterEach(cleanup); + +const REPO_FRONT_ID = "repo-front"; +const REPO_BACK_ID = "repo-back"; + +function makeRepo(id: string, name: string): Repository { + return { + id, + workspace_id: "ws-1", + name, + source_type: "local", + local_path: `/repos/${name}`, + default_branch: "main", + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + } as Repository; +} + +function row(overrides: Partial = {}): TaskRepoRow { + return { key: `row-${Math.random()}`, branch: "", ...overrides }; +} + +function makeFs(overrides: Partial): DialogFormState { + // Only the fields RepoChipsRow actually reads/sets need to be real. + return { + repositories: [] as TaskRepoRow[], + useGitHubUrl: false, + discoveredRepositories: [], + githubUrl: "", + githubUrlError: null, + githubBranch: "", + githubBranches: [] as Branch[], + githubBranchesLoading: false, + setGitHubBranch: vi.fn(), + addRepository: vi.fn(), + removeRepository: vi.fn(), + updateRepository: vi.fn(), + setRepositories: vi.fn(), + ...overrides, + } as unknown as DialogFormState; +} + +// eslint-disable-next-line @typescript-eslint/no-unused-vars -- noop for test-only callback signature +const NOOP = (_key: string, _value: string) => undefined; + +// eslint-disable-next-line max-lines-per-function -- test describe block, splitting hurts readability +describe("RepoChipsRow", () => { + it("renders one chip per row plus an Add button", () => { + render( + , + ); + expect(screen.getAllByTestId("repo-chip")).toHaveLength(1); + expect(screen.getByTestId("add-repository")).toBeTruthy(); + }); + + it("renders one chip per row across multiple repos", () => { + render( + , + ); + expect(screen.getAllByTestId("repo-chip")).toHaveLength(2); + }); + + it("renders the GitHub URL input in URL mode (chips suppressed)", () => { + render( + undefined} + onGitHubUrlChange={() => undefined} + />, + ); + expect(screen.getByTestId("repo-chips-row")).toBeTruthy(); + expect(screen.queryAllByTestId("repo-chip")).toHaveLength(0); + expect(screen.getByTestId("github-url-input")).toBeTruthy(); + }); + + it("hides the chip row when the task is already started", () => { + render( + , + ); + expect(screen.queryByTestId("repo-chips-row")).toBeNull(); + }); + + it("disables Add when no more repositories are available", () => { + render( + , + ); + const btn = screen.getByTestId("add-repository") as HTMLButtonElement; + expect(btn.disabled).toBe(true); + }); + + it("calls fs.addRepository when the + button is clicked", () => { + const fs = makeFs({ repositories: [row({ key: "r0", repositoryId: REPO_FRONT_ID })] }); + render( + , + ); + fireEvent.click(screen.getByTestId("add-repository")); + expect(fs.addRepository).toHaveBeenCalledOnce(); + }); + + it("removing a chip calls fs.removeRepository(key)", () => { + const fs = makeFs({ + repositories: [ + row({ key: "r0", repositoryId: REPO_FRONT_ID, branch: "main" }), + row({ key: "r1", repositoryId: REPO_BACK_ID, branch: "develop" }), + ], + }); + render( + , + ); + fireEvent.click(screen.getAllByTestId("remove-repo-chip")[0]); + expect(fs.removeRepository).toHaveBeenCalledWith("r0"); + }); + + // Regression: discovered (on-machine) repos must surface in the picker + // dropdown alongside workspace repos. A previous rewrite passed [] for + // discovered repos and lost them. + it("includes discovered (on-machine) repos in the picker dropdown", () => { + render( + , + ); + fireEvent.click(screen.getByTestId("repo-chip-trigger")); + expect(screen.getByText("frontend")).toBeTruthy(); + expect(screen.getByText("projects/local-project")).toBeTruthy(); + expect(screen.queryByText("frontend-dup")).toBeNull(); + }); + + // Regression: picking a discovered repo passes its path as the value; + // onRowRepositoryChange must receive it (the handler then resolves to a + // workspace id or local path). Previously the chip wrote the path into + // a workspace `repository_id`, causing FK failures on submit. + it("calls onRowRepositoryChange with the discovered path when picked", () => { + const onRowRepositoryChange = vi.fn(); + render( + , + ); + fireEvent.click(screen.getByTestId("repo-chip-trigger")); + fireEvent.click(screen.getByText("projects/local-project")); + expect(onRowRepositoryChange).toHaveBeenCalledWith("r0", "/home/me/projects/local-project"); + }); + + // Regression: discovered (path-keyed) rows used to call the branch loader + // with no path source, so their branch picker stayed empty. The chip must + // build a `kind: "path"` source for discovered rows so the unified hook + // hits the path-based query param instead of trying an id lookup. + it("discovered rows build a path-source for the branch loader", () => { + render( + , + ); + expect(lastBranchSource.value).toEqual({ + kind: "path", + workspaceId: "ws-1", + path: "/home/me/projects/proj", + }); + }); + + it("workspace rows build an id-source for the branch loader", () => { + render( + , + ); + expect(lastBranchSource.value).toEqual({ + kind: "id", + workspaceId: "ws-1", + repositoryId: REPO_FRONT_ID, + }); + }); + + // Regression: remote branches must keep their "origin/" prefix so users + // can distinguish a local "main" from "origin/main". A prior rewrite + // dropped the prefix, producing two indistinguishable rows. + it("remote branches keep their origin/ prefix and don't collide with local names", () => { + mockBranches.value = { + isLoading: false, + branches: [ + { name: "main", type: "local" }, + { name: "main", type: "remote", remote: "origin" }, + ] as unknown as Branch[], + }; + render( + , + ); + fireEvent.click(screen.getByTestId("branch-chip-trigger")); + expect(screen.getByText("main")).toBeTruthy(); + expect(screen.getByText("origin/main")).toBeTruthy(); + // Reset for sibling tests. + mockBranches.value = { branches: [], isLoading: false }; + }); +}); diff --git a/apps/web/components/task-create-dialog-repo-chips.tsx b/apps/web/components/task-create-dialog-repo-chips.tsx new file mode 100644 index 0000000000..ab8ad1a023 --- /dev/null +++ b/apps/web/components/task-create-dialog-repo-chips.tsx @@ -0,0 +1,609 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import { IconPlus, IconX, IconCode, IconGitBranch, IconGitFork } from "@tabler/icons-react"; +import { cn } from "@/lib/utils"; +import { Popover, PopoverContent, PopoverTrigger } from "@kandev/ui/popover"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@kandev/ui/command"; +import { useBranches, type BranchSource } from "@/hooks/domains/workspace/use-repository-branches"; +import type { Branch, LocalRepository, Repository } from "@/lib/types/http"; +import type { DialogFormState, TaskRepoRow } from "@/components/task-create-dialog-types"; +import { autoSelectBranch } from "@/components/task-create-dialog-helpers"; +import { BranchRefreshButton } from "@/components/branch-refresh-button"; + +/** + * Chip row for the task-create dialog. Renders one chip per row in + * `fs.repositories`, plus a trailing "+" to add another. Each chip is two + * pills (repo, branch) and a remove (×). All chips are equivalent — there + * is no "primary" — and any row can hold either a workspace repo or a + * discovered on-machine path. + * + * In GitHub URL mode the chips are replaced by an inline URL input pill; + * the trailing toggle flips between the two modes. + */ +type RepoChipsRowProps = { + fs: DialogFormState; + repositories: Repository[]; + isTaskStarted: boolean; + /** Required for loading branches on discovered (path-keyed) rows. */ + workspaceId: string | null; + /** + * Per-row repo change handler. Resolves the picked value into either a + * workspace `repositoryId` or a discovered `localPath` and writes that + * into the row. Comes from useDialogHandlers so the resolution logic + * stays in one place. + */ + onRowRepositoryChange: (key: string, value: string) => void; + onRowBranchChange: (key: string, value: string) => void; + /** GitHub URL flow lives alongside the chips so users can switch in place. */ + onToggleGitHubUrl?: () => void; + onGitHubUrlChange?: (value: string) => void; + /** + * Fresh-branch toggle props. When `freshBranchAvailable` is true the toggle + * renders inline at the right edge of the chip row so it sits next to the + * branch pills it affects, instead of taking its own row under the + * agent/executor selectors. + */ + freshBranchAvailable?: boolean; + freshBranchEnabled?: boolean; + onToggleFreshBranch?: (enabled: boolean) => void; + /** + * Lock branch pills when the task runs on the local executor — the user's + * actual checkout dictates the branch, and changing it would mutate their + * working tree. Fresh-branch mode unlocks it (we're explicitly forking a + * new branch from a chosen base). + */ + isLocalExecutor?: boolean; +}; + +export function RepoChipsRow({ + fs, + repositories, + isTaskStarted, + workspaceId, + onRowRepositoryChange, + onRowBranchChange, + onToggleGitHubUrl, + onGitHubUrlChange, + freshBranchAvailable, + freshBranchEnabled, + onToggleFreshBranch, + isLocalExecutor, +}: RepoChipsRowProps) { + const branchLocked = !!isLocalExecutor && !freshBranchEnabled; + // No early returns above hooks. URL mode and started-state checks happen below. + const usedIds = useMemo(() => collectUsedRepoIds(fs.repositories), [fs.repositories]); + if (isTaskStarted) return null; + + const remainingCount = repositories.filter((r) => !usedIds.has(r.id)).length; + // Add stays enabled when no workspace repos are left, since users can also + // pick from discovered on-machine paths. + const hasDiscovered = fs.discoveredRepositories.length > 0; + const canAddMore = remainingCount > 0 || hasDiscovered; + const addHint = canAddMore ? undefined : "All workspace repositories are already added"; + + return ( +
+ {fs.useGitHubUrl ? ( + + ) : ( + + )} + {freshBranchAvailable && onToggleFreshBranch && ( + + )} + {onToggleGitHubUrl && ( + + )} +
+ ); +} + +/** + * Renders the list of repo chips plus the trailing "+ add repository" + * button. Extracted from RepoChipsRow so the parent stays under the + * function-length cap; logic is unchanged. + */ +function ChipsList({ + fs, + repositories, + workspaceId, + branchLocked, + canAddMore, + addHint, + onRowRepositoryChange, + onRowBranchChange, +}: { + fs: DialogFormState; + repositories: Repository[]; + workspaceId: string | null; + branchLocked: boolean; + canAddMore: boolean; + addHint?: string; + onRowRepositoryChange: (key: string, value: string) => void; + onRowBranchChange: (key: string, value: string) => void; +}) { + return ( + <> + {fs.repositories.map((row) => ( + onRowRepositoryChange(row.key, value)} + onBranchChange={(value) => onRowBranchChange(row.key, value)} + onRemove={() => fs.removeRepository(row.key)} + /> + ))} + + + ); +} + +/** Build the set of repo identifiers (workspace id or path) currently in use. */ +function collectUsedRepoIds(rows: TaskRepoRow[], exceptKey?: string): Set { + const ids = new Set(); + for (const r of rows) { + if (r.key === exceptKey) continue; + if (r.repositoryId) ids.add(r.repositoryId); + if (r.localPath) ids.add(r.localPath); + } + return ids; +} + +type RepoChipProps = { + row: TaskRepoRow; + /** Required for path-based branch loading on discovered rows. */ + workspaceId: string | null; + repositories: Repository[]; + discoveredRepositories: LocalRepository[]; + /** Repo IDs/paths to filter out of the dropdown (already in use elsewhere). */ + excludedRepoIds: Set; + /** + * Lock the branch pill regardless of branch availability. Used for the + * local executor where the user's actual checkout dictates the branch + * (and changing it would mutate their working tree). Fresh-branch mode + * unlocks it because we're explicitly creating a new branch from a base. + */ + branchLocked?: boolean; + /** + * Optional display override for the branch value. Used when the chip + * is `branchLocked` so we surface the repo's actual current branch + * instead of whatever the user picked under a different executor. + */ + branchOverride?: string; + onRepositoryChange: (value: string) => void; + onBranchChange: (value: string) => void; + onRemove: () => void; +}; + +function useRepoChipData({ + row, + workspaceId, + repositories, + discoveredRepositories, + excludedRepoIds, + onBranchChange, +}: Pick< + RepoChipProps, + | "row" + | "workspaceId" + | "repositories" + | "discoveredRepositories" + | "excludedRepoIds" + | "onBranchChange" +>) { + const filteredRepos = useMemo( + () => repositories.filter((r) => !excludedRepoIds.has(r.id) || r.id === row.repositoryId), + [repositories, excludedRepoIds, row.repositoryId], + ); + // Discovered (on-machine) repos not yet imported into the workspace. Drop + // ones whose path is already a workspace repo so the dropdown doesn't show + // the same folder twice. + const filteredDiscovered = useMemo(() => { + const workspaceRepoPaths = new Set( + filteredRepos + .map((r) => r.local_path) + .filter(Boolean) + .map((path: string) => normalizeRepoPath(path)), + ); + return discoveredRepositories.filter( + (r) => + !workspaceRepoPaths.has(normalizeRepoPath(r.path)) && + (!excludedRepoIds.has(r.path) || r.path === row.localPath), + ); + }, [filteredRepos, discoveredRepositories, excludedRepoIds, row.localPath]); + + // One hook for both row sources (workspace repo by id OR on-machine path). + // Backend resolves either to an absolute path; the Zustand cache keys + // path-based entries with a synthetic key so workspace + discovered share + // one slice (and the same fetch dedupe). + const branchSource = useMemo(() => { + if (!workspaceId) return null; + if (row.repositoryId) { + return { kind: "id", workspaceId, repositoryId: row.repositoryId }; + } + if (row.localPath) { + return { kind: "path", workspaceId, path: row.localPath }; + } + return null; + }, [workspaceId, row.repositoryId, row.localPath]); + const { + branches, + isLoading: branchesLoading, + refresh: refreshBranches, + } = useBranches(branchSource, !!branchSource); + + // Once branches load for this row, pre-fill the branch pill with the user's + // last-used branch (when present) or main/master/origin/main/origin/master. + // Skipped if the user already picked a branch on this row. + useEffect(() => { + if (!branchSource || branchesLoading || branches.length === 0 || row.branch) return; + autoSelectBranch(branches, onBranchChange); + }, [branchSource, branchesLoading, branches, row.branch, onBranchChange]); + + const repoOptions: PillOption[] = useMemo( + () => [ + ...filteredRepos.map((r) => ({ value: r.id, label: r.name })), + ...filteredDiscovered.map((r) => ({ + value: r.path, + label: shortRepoPath(r.path), + keywords: [r.path], + })), + ], + [filteredRepos, filteredDiscovered], + ); + const branchOptions: PillOption[] = useMemo(() => branches.map(branchToOption), [branches]); + return { repoOptions, branchOptions, branchesLoading, refreshBranches }; +} + +function RepoChip({ + row, + workspaceId, + repositories, + discoveredRepositories, + excludedRepoIds, + branchLocked, + branchOverride, + onRepositoryChange, + onBranchChange, + onRemove, +}: RepoChipProps) { + const { repoOptions, branchOptions, branchesLoading, refreshBranches } = useRepoChipData({ + row, + workspaceId, + repositories, + discoveredRepositories, + excludedRepoIds, + onBranchChange, + }); + const repoLabel = + repositories.find((r) => r.id === row.repositoryId)?.name ?? + discoveredRepositories + .find((r) => r.path === row.localPath) + ?.path?.split("/") + .pop() ?? + ""; + const hasRepo = !!(row.repositoryId || row.localPath); + const branchPlaceholder = computeBranchPlaceholder( + hasRepo, + branchesLoading, + branchOptions.length, + ); + + return ( + + } + value={repoLabel} + placeholder="repository" + options={repoOptions} + onSelect={onRepositoryChange} + searchPlaceholder="Search repositories..." + emptyMessage="No repositories" + testId="repo-chip-trigger" + /> + } + value={branchOverride ?? row.branch} + placeholder={branchPlaceholder} + options={branchOptions} + onSelect={onBranchChange} + disabled={branchLocked || !hasRepo || branchesLoading || branchOptions.length === 0} + searchPlaceholder="Search branches..." + emptyMessage="No branches" + testId="branch-chip-trigger" + onRefresh={refreshBranches} + refreshing={branchesLoading} + /> + + + ); +} + +type PillOption = { value: string; label: string; keywords?: string[] }; + +function computeBranchPlaceholder(hasRepo: boolean, loading: boolean, optionCount: number): string { + if (!hasRepo) return "branch"; + if (loading) return "loading…"; + if (optionCount === 0) return "no branches"; + return "branch"; +} + +function normalizeRepoPath(path: string): string { + return path.replace(/\\/g, "/").replace(/\/+$/g, ""); +} + +function shortRepoPath(path: string): string { + // Show the trailing folder name with one parent for context (e.g. "myorg/myrepo"). + const parts = path.replace(/\\/g, "/").split("/").filter(Boolean); + if (parts.length <= 1) return path; + return parts.slice(-2).join("/"); +} + +function branchToOption(b: Branch): PillOption { + // Remote branches keep their "origin/" prefix so they're distinguishable + // from local branches with the same short name (e.g. "main" vs "origin/main"). + // Without the prefix, the dropdown shows two indistinguishable rows. + const display = b.type === "remote" && b.remote ? `${b.remote}/${b.name}` : b.name; + return { value: display, label: display }; +} + +/** + * Compact pill trigger that opens a popover with a search list. Auto-widths + * to its content (no `w-full`, no chevron) so multiple pills can sit on one + * line without overlapping or stretching to fill the row. + */ +function Pill({ + icon, + value, + placeholder, + options, + onSelect, + disabled = false, + searchPlaceholder, + emptyMessage, + testId, + onRefresh, + refreshing, +}: { + icon: React.ReactNode; + value: string; + placeholder: string; + options: PillOption[]; + onSelect: (value: string) => void; + disabled?: boolean; + searchPlaceholder: string; + emptyMessage: string; + testId?: string; + /** Optional refresh action rendered next to the search input. */ + onRefresh?: () => void; + /** Show the refresh icon as spinning + disabled while a refresh is in flight. */ + refreshing?: boolean; +}) { + const [open, setOpen] = useState(false); + const hasValue = !!value; + return ( + + + + + + +
+ + {onRefresh && } +
+ + {emptyMessage} + + {options.map((option) => ( + { + onSelect(option.value); + setOpen(false); + }} + > + {option.label} + + ))} + + +
+
+
+ ); +} + +function GitHubUrlSection({ + githubUrl, + githubUrlError, + githubBranch, + githubBranches, + githubBranchesLoading, + onGitHubUrlChange, + onGitHubBranchChange, +}: { + githubUrl: string; + githubUrlError: string | null; + githubBranch: string; + githubBranches: Branch[]; + githubBranchesLoading: boolean; + onGitHubUrlChange?: (value: string) => void; + onGitHubBranchChange: (value: string) => void; +}) { + // URL mode is always remote-clone-based, so the branch is freely + // selectable regardless of executor type. Local executor's "branch is + // dictated by the user's checkout" rule does not apply here — there is + // no pre-existing local checkout for an arbitrary GitHub URL. + const branchOptions: PillOption[] = useMemo( + () => githubBranches.map(branchToOption), + [githubBranches], + ); + const branchPlaceholder = computeBranchPlaceholder( + !!githubUrl.trim(), + githubBranchesLoading, + branchOptions.length, + ); + return ( + <> + + } + value={githubBranch} + placeholder={branchPlaceholder} + options={branchOptions} + onSelect={onGitHubBranchChange} + disabled={!githubUrl.trim() || githubBranchesLoading || branchOptions.length === 0} + searchPlaceholder="Search branches..." + emptyMessage="No branches" + testId="branch-chip-trigger" + /> + + ); +} + +function GitHubUrlPill({ + githubUrl, + githubUrlError, + onGitHubUrlChange, +}: { + githubUrl: string; + githubUrlError: string | null; + onGitHubUrlChange?: (value: string) => void; +}) { + 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} + className={cn( + "h-7 rounded-md px-2.5 text-xs bg-muted/30 border border-border/60", + "outline-none focus:bg-muted focus:border-border placeholder:text-muted-foreground", + githubUrlError && "border-destructive text-destructive", + )} + autoFocus + /> + {githubUrlError && ( + + )} +
+ ); +} diff --git a/apps/web/components/task-create-dialog-repositories-state.ts b/apps/web/components/task-create-dialog-repositories-state.ts new file mode 100644 index 0000000000..3368a1258d --- /dev/null +++ b/apps/web/components/task-create-dialog-repositories-state.ts @@ -0,0 +1,33 @@ +"use client"; + +import { useCallback, useRef, useState } from "react"; +import type { TaskRepoRow } from "@/components/task-create-dialog-types"; + +/** + * Manages the unified `repositories` list for task creation. Every chip + * (one or many) is an entry; there is no "primary" or "extras" split. + * + * `nextKey` increments to give each row a stable client-side key without + * relying on array indices (which would shift on removal and break + * uncontrolled inputs). + */ +export function useRepositoriesState() { + const [repositories, setRepositories] = useState([]); + const nextKeyRef = useRef(0); + + const addRepository = useCallback(() => { + nextKeyRef.current += 1; + const key = `row-${nextKeyRef.current}`; + setRepositories((rows) => [...rows, { key, branch: "" }]); + }, []); + + const removeRepository = useCallback((key: string) => { + setRepositories((rows) => rows.filter((r) => r.key !== key)); + }, []); + + const updateRepository = useCallback((key: string, patch: Partial) => { + setRepositories((rows) => rows.map((r) => (r.key === key ? { ...r, ...patch } : r))); + }, []); + + return { repositories, setRepositories, addRepository, removeRepository, updateRepository }; +} diff --git a/apps/web/components/task-create-dialog-selectors.tsx b/apps/web/components/task-create-dialog-selectors.tsx index 3099092e8f..79934d7012 100644 --- a/apps/web/components/task-create-dialog-selectors.tsx +++ b/apps/web/components/task-create-dialog-selectors.tsx @@ -85,6 +85,7 @@ type BranchSelectorProps = { placeholder: string; searchPlaceholder: string; emptyMessage: string; + triggerClassName?: string; onRefresh?: () => void; refreshing?: boolean; fetchedAt?: string; @@ -100,6 +101,7 @@ export const BranchSelector = memo(function BranchSelector({ placeholder, searchPlaceholder, emptyMessage, + triggerClassName, onRefresh, refreshing, fetchedAt, @@ -125,6 +127,7 @@ export const BranchSelector = memo(function BranchSelector({ disabled={disabled} dropdownLabel="Base Branch" className={disabled ? undefined : CURSOR_POINTER_CLASS} + triggerClassName={triggerClassName} testId="branch-selector" filter={scoreBranch} headerAction={headerAction} @@ -260,10 +263,9 @@ export const InlineTaskName = memo(function InlineTaskName({ type="text" value={value} onChange={(e) => onChange(e.target.value)} - placeholder="task-name" + placeholder="Task name" data-testid="task-title-input" - size={Math.max(value.length, 9)} - className="bg-transparent border-none outline-none focus:ring-0 text-sm font-medium min-w-0 rounded-md px-1.5 py-0.5 -mx-1.5 hover:bg-muted focus:bg-muted transition-colors" + className="w-full bg-transparent border-none outline-none focus:ring-0 text-sm font-medium rounded-md px-2 py-1 -mx-2 hover:bg-muted/30 focus:bg-muted/30 transition-colors placeholder:text-muted-foreground/70" /> ); }); diff --git a/apps/web/components/task-create-dialog-setup.tsx b/apps/web/components/task-create-dialog-setup.tsx index 6c91d50b4b..7cc1b9a150 100644 --- a/apps/web/components/task-create-dialog-setup.tsx +++ b/apps/web/components/task-create-dialog-setup.tsx @@ -169,12 +169,12 @@ function useSubmitHandlersWiring({ workflowId, effectiveWorkflowId: computed.effectiveWorkflowId, effectiveDefaultStepId: computed.effectiveDefaultStepId, - repositoryId: fs.repositoryId, - selectedLocalRepo: fs.selectedLocalRepo, + repositories: fs.repositories, + discoveredRepositories: fs.discoveredRepositories, useGitHubUrl: fs.useGitHubUrl, githubUrl: fs.githubUrl, githubPrHeadBranch: fs.githubPrHeadBranch, - branch: fs.branch, + githubBranch: fs.githubBranch, agentProfileId: computed.effectiveAgentProfileId, executorId: fs.executorId, executorProfileId: fs.executorProfileId, @@ -190,8 +190,8 @@ function useSubmitHandlersWiring({ setHasTitle: fs.setHasTitle, setHasDescription: fs.setHasDescription, setTaskName: fs.setTaskName, - setRepositoryId: fs.setRepositoryId, - setBranch: fs.setBranch, + setRepositories: fs.setRepositories, + setGitHubBranch: fs.setGitHubBranch, setAgentProfileId: fs.setAgentProfileId, setExecutorId: fs.setExecutorId, setSelectedWorkflowId: fs.setSelectedWorkflowId, @@ -221,17 +221,18 @@ export function useTaskCreateDialogSetup(props: TaskCreateDialogProps) { snapshots, repositories, repositoriesLoading, - branches, branchesLoading, - refreshBranches, - branchesFetchedAt, - branchesFetchError, computed, } = useTaskCreateDialogData(open, workspaceId, workflowId, defaultStepId, fs); + // Multi-repo: the "repo local path" used by the fresh-branch preflight is + // the first chip's path. Empty when no chip has a repo selected (URL mode + // or empty rows). Discovered (path-only) chips short-circuit the lookup. const repositoryLocalPath = (() => { - if (fs.selectedLocalRepo) return fs.selectedLocalRepo.path; - if (fs.repositoryId) { - const repo = repositories.find((r) => r.id === fs.repositoryId); + const first = fs.repositories[0]; + if (!first) return ""; + if (first.localPath) return first.localPath; + if (first.repositoryId) { + const repo = repositories.find((r) => r.id === first.repositoryId); return repo?.local_path ?? ""; } return ""; @@ -242,7 +243,6 @@ export function useTaskCreateDialogSetup(props: TaskCreateDialogProps) { workflowId, repositories, repositoriesLoading, - branches, agentProfiles, executors, workspaceDefaults: computed.workspaceDefaults, @@ -274,9 +274,6 @@ export function useTaskCreateDialogSetup(props: TaskCreateDialogProps) { snapshots, repositoriesLoading, branchesLoading, - refreshBranches, - branchesFetchedAt, - branchesFetchError, computed, handlers, submitHandlers, diff --git a/apps/web/components/task-create-dialog-state.ts b/apps/web/components/task-create-dialog-state.ts index b88c9159d2..37d3e90fc2 100644 --- a/apps/web/components/task-create-dialog-state.ts +++ b/apps/web/components/task-create-dialog-state.ts @@ -5,7 +5,6 @@ import type { LocalRepository, Workspace, ExecutorProfile, Branch } from "@/lib/ import type { TaskFormInputsHandle } from "@/components/task-create-dialog-types"; import { useAppStore } from "@/components/state-provider"; import { useRepositories } from "@/hooks/domains/workspace/use-repositories"; -import { useRepositoryBranches } from "@/hooks/domains/workspace/use-repository-branches"; import { useSettingsData } from "@/hooks/domains/settings/use-settings-data"; import { useRepositoryOptions, @@ -16,13 +15,27 @@ import { useIsLocalExecutor, } from "@/components/task-create-dialog-options"; import { getTaskCreateDraft, setTaskCreateDraft, removeTaskCreateDraft } from "@/lib/local-storage"; + +/** + * Multi-repo tasks currently only run on the git-worktree executor — + * Docker/Sprites/etc. don't yet know how to provision N sibling repos under + * one task root. Returning a non-null reason marks the option as disabled in + * the executor selector and surfaces this string as a tooltip. The dialog + * only applies this when 2+ repos are selected (see isMultiRepoSelection). + */ +function nonWorktreeDisabledReason(profile: ExecutorProfile): string | null { + if ((profile.executor_type ?? "") === "worktree") return null; + return "Multi-repo tasks only support the git-worktree executor."; +} import type { StepType, TaskCreateDialogInitialValues, DialogFormState, DialogComputedValues, DialogComputedArgs, + TaskRepoRow, } from "@/components/task-create-dialog-types"; +import { useRepositoriesState } from "@/components/task-create-dialog-repositories-state"; import { computePassthroughProfile } from "@/components/task-create-dialog-helpers"; import { computeDialogDefaultStepId, @@ -40,17 +53,14 @@ type FormResetters = { setTaskName: (v: string) => void; setHasTitle: (v: boolean) => void; setHasDescription: (v: boolean) => void; - setRepositoryId: (v: string) => void; - setBranch: (v: string) => void; + setRepositories: (v: TaskRepoRow[]) => void; + setGitHubBranch: (v: string) => void; setAgentProfileId: (v: string) => void; setExecutorId: (v: string) => void; setExecutorProfileId: (v: string) => void; setSelectedWorkflowId: (v: string | null) => void; setFetchedSteps: (v: StepType[] | null) => void; setDiscoveredRepositories: (v: LocalRepository[]) => void; - setDiscoveredRepoPath: (v: string) => void; - setSelectedLocalRepo: (v: LocalRepository | null) => void; - setLocalBranches: (v: Branch[]) => void; setDiscoverReposLoaded: (v: boolean) => void; setUseGitHubUrl: (v: boolean) => void; setGitHubUrl: (v: string) => void; @@ -143,8 +153,21 @@ function resetTaskForm( resetters.setTaskName(name); resetters.setHasTitle(name.trim().length > 0); resetters.setHasDescription(description.trim().length > 0); - resetters.setRepositoryId(initialValues?.repositoryId ?? ""); - resetters.setBranch(initialValues?.branch ?? ""); + // Seed the unified repos list from initialValues. A repo + branch pre-fill + // becomes a single row; nothing seeds an empty list (the auto-select + // effect later picks the user's last-used repo or the first workspace one). + if (initialValues?.repositoryId) { + resetters.setRepositories([ + { + key: "row-0", + repositoryId: initialValues.repositoryId, + branch: initialValues.branch ?? "", + }, + ]); + } else { + resetters.setRepositories([]); + } + resetters.setGitHubBranch(initialValues?.branch ?? ""); resetters.setAgentProfileId(""); resetters.setExecutorId(""); resetters.setExecutorProfileId(""); @@ -156,9 +179,6 @@ function resetTaskForm( function resetDiscoveryState(resetters: FormResetters, iv?: TaskCreateDialogInitialValues) { const ghUrl = iv?.githubUrl ?? ""; resetters.setDiscoveredRepositories([]); - resetters.setDiscoveredRepoPath(""); - resetters.setSelectedLocalRepo(null); - resetters.setLocalBranches([]); resetters.setDiscoverReposLoaded(false); resetters.setUseGitHubUrl(Boolean(ghUrl)); resetters.setGitHubUrl(ghUrl); @@ -277,8 +297,9 @@ function useFormStateValues( const [draftDescription, setDraftDescription] = useState(""); const descriptionInputRef = useRef(null); - const [repositoryId, setRepositoryId] = useState(initialValues?.repositoryId ?? ""); - const [branch, setBranch] = useState(initialValues?.branch ?? ""); + // GitHub URL flow has its own branch field (the per-repo branch lives on + // each row in `repositories`). Seed from initialValues for the URL flow only. + const [githubBranch, setGitHubBranch] = useState(initialValues?.branch ?? ""); const [agentProfileId, setAgentProfileId] = useState(""); const [executorId, setExecutorId] = useState(""); const [executorProfileId, setExecutorProfileId] = useState(""); @@ -296,10 +317,8 @@ function useFormStateValues( draftDescription, setDraftDescription, descriptionInputRef, - repositoryId, - setRepositoryId, - branch, - setBranch, + githubBranch, + setGitHubBranch, agentProfileId, setAgentProfileId, executorId, @@ -322,26 +341,17 @@ function useFormStateValues( }; } -/** Repository discovery state */ +/** Repository discovery state — just the discovered list. The previous + * per-form `selectedLocalRepo` / `discoveredRepoPath` / `localBranches` + * primary-only fields are gone; discovered repos now live as ordinary rows + * in `fs.repositories` with `localPath` set. */ function useDiscoveryState() { const [discoveredRepositories, setDiscoveredRepositories] = useState([]); - const [discoveredRepoPath, setDiscoveredRepoPath] = useState(""); - const [selectedLocalRepo, setSelectedLocalRepo] = useState(null); - const [localBranches, setLocalBranches] = useState([]); - const [localBranchesLoading, setLocalBranchesLoading] = useState(false); const [discoverReposLoading, setDiscoverReposLoading] = useState(false); const [discoverReposLoaded, setDiscoverReposLoaded] = useState(false); return { discoveredRepositories, setDiscoveredRepositories, - discoveredRepoPath, - setDiscoveredRepoPath, - selectedLocalRepo, - setSelectedLocalRepo, - localBranches, - setLocalBranches, - localBranchesLoading, - setLocalBranchesLoading, discoverReposLoading, setDiscoverReposLoading, discoverReposLoaded, @@ -359,6 +369,7 @@ export function useDialogFormState( const discovery = useDiscoveryState(); const ghUrl = useGitHubUrlState(); const wfAgent = useWorkflowAgentProfileState(); + const repos = useRepositoriesState(); const freshBranch = useFreshBranchState(); useFormResetEffects({ @@ -374,17 +385,14 @@ export function useDialogFormState( setTaskName: form.setTaskName, setHasTitle: form.setHasTitle, setHasDescription: form.setHasDescription, - setRepositoryId: form.setRepositoryId, - setBranch: form.setBranch, + setRepositories: repos.setRepositories, + setGitHubBranch: form.setGitHubBranch, setAgentProfileId: form.setAgentProfileId, setExecutorId: form.setExecutorId, setExecutorProfileId: form.setExecutorProfileId, setSelectedWorkflowId: form.setSelectedWorkflowId, setFetchedSteps: form.setFetchedSteps, setDiscoveredRepositories: discovery.setDiscoveredRepositories, - setDiscoveredRepoPath: discovery.setDiscoveredRepoPath, - setSelectedLocalRepo: discovery.setSelectedLocalRepo, - setLocalBranches: discovery.setLocalBranches, setDiscoverReposLoaded: discovery.setDiscoverReposLoaded, setUseGitHubUrl: ghUrl.setUseGitHubUrl, setGitHubUrl: ghUrl.setGitHubUrl, @@ -404,7 +412,7 @@ export function useDialogFormState( form.descriptionInputRef, ); - return { ...form, ...discovery, ...ghUrl, ...wfAgent, ...freshBranch, clearDraft }; + return { ...form, ...discovery, ...ghUrl, ...wfAgent, ...repos, ...freshBranch, clearDraft }; } export type { DialogFormState } from "@/components/task-create-dialog-types"; @@ -415,6 +423,7 @@ export { } from "@/components/task-create-dialog-helpers"; export { useTaskCreateDialogEffects } from "@/components/task-create-dialog-effects"; +// useDialogHandlers lives in ./task-create-dialog-handlers.ts export { useDialogHandlers } from "@/components/task-create-dialog-handlers"; export function useDialogComputed({ @@ -423,7 +432,6 @@ export function useDialogComputed({ workspaceId, workflowId, defaultStepId, - branches, settingsData, agentProfiles, workspaces, @@ -464,15 +472,17 @@ export function useDialogComputed({ const workspaceDefaults = workspaceId ? workspaces.find((ws: Workspace) => ws.id === workspaceId) : null; + // The form has a repo selection when either: (a) any chip in the unified + // list has a repo set, or (b) URL mode has a non-empty URL. The chip row + // takes care of branch state per row; there is no global branch. const hasRepositorySelection = Boolean( - fs.repositoryId || fs.selectedLocalRepo || (fs.useGitHubUrl && fs.githubUrl.trim()), + fs.repositories.some((r) => r.repositoryId || r.localPath) || + (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); + // Branch options are only used by the URL-mode flow now (the chip's branch + // pill loads branches per-repo). Keep the computed value but always feed it + // the URL branches when in URL mode. + const branchOptions = useBranchOptions(fs.useGitHubUrl ? fs.githubBranches : []); const agentProfileOptions = useAgentProfileOptions(agentProfiles); const allExecutorProfiles = useMemo(() => { return executors.flatMap((executor) => @@ -483,7 +493,15 @@ export function useDialogComputed({ })), ); }, [executors]); - const executorProfileOptions = useExecutorProfileOptions(allExecutorProfiles); + // Multi-repo tasks only run on the git-worktree executor today — Docker / + // Sprites / standalone don't yet know how to provision N sibling repos. Gate + // non-worktree options only when 2+ repos are selected; single-repo tasks + // keep the full executor catalogue. + const isMultiRepoSelection = + fs.repositories.filter((r) => r.repositoryId || r.localPath).length > 1; + const executorProfileOptions = useExecutorProfileOptions(allExecutorProfiles, { + disabledReasonFor: isMultiRepoSelection ? nonWorktreeDisabledReason : undefined, + }); const executorHint = useExecutorHint(executors, fs.executorId); const isLocalExecutor = useIsLocalExecutor(executors, fs.executorId); const { headerRepositoryOptions } = useRepositoryOptions(repositories, fs.discoveredRepositories); @@ -542,20 +560,16 @@ export function useTaskCreateDialogData( useSettingsData(open); const { repositories, isLoading: repositoriesLoading } = useRepositories(workspaceId, open); - const { - branches, - isLoading: branchesLoading, - refresh: refreshBranches, - fetchedAt: branchesFetchedAt, - fetchError: branchesFetchError, - } = useRepositoryBranches(fs.repositoryId || null, Boolean(open && fs.repositoryId)); + // Per-repo branch loading lives in each chip now (RepoChipsRow). No + // global branch query is needed here — the chip uses useRepositoryBranches + // for its own row, and the store dedupes by repositoryId. + const branchesLoading = false; const computed = useDialogComputed({ fs, open, workspaceId, workflowId, defaultStepId, - branches, settingsData, agentProfiles, workspaces, @@ -572,11 +586,7 @@ export function useTaskCreateDialogData( snapshots, repositories, repositoriesLoading, - branches, branchesLoading, - refreshBranches, - branchesFetchedAt, - branchesFetchError, computed, }; } diff --git a/apps/web/components/task-create-dialog-submit.tsx b/apps/web/components/task-create-dialog-submit.tsx index 1a0667a48d..2717feba53 100644 --- a/apps/web/components/task-create-dialog-submit.tsx +++ b/apps/web/components/task-create-dialog-submit.tsx @@ -31,12 +31,12 @@ export function useTaskSubmitHandlers({ workflowId, effectiveWorkflowId, effectiveDefaultStepId, - repositoryId, - selectedLocalRepo, + repositories, + discoveredRepositories, useGitHubUrl, githubUrl, githubPrHeadBranch, - branch, + githubBranch, agentProfileId, executorId, executorProfileId, @@ -52,8 +52,8 @@ export function useTaskSubmitHandlers({ setHasTitle, setHasDescription, setTaskName, - setRepositoryId, - setBranch, + setRepositories, + setGitHubBranch, setAgentProfileId, setExecutorId, setSelectedWorkflowId, @@ -88,20 +88,19 @@ export function useTaskSubmitHandlers({ trimmedTitle, workspaceId, effectiveWorkflowId, - repositoryId, - selectedLocalRepo, + repositories, githubUrl, agentProfileId, }), - [workspaceId, effectiveWorkflowId, repositoryId, selectedLocalRepo, githubUrl, agentProfileId], + [workspaceId, effectiveWorkflowId, repositories, githubUrl, agentProfileId], ); const resetForm = useCallback(() => { setHasTitle(false); setHasDescription(false); setTaskName(""); - setRepositoryId(""); - setBranch(""); + setRepositories([]); + setGitHubBranch(""); setAgentProfileId(""); setExecutorId(""); setSelectedWorkflowId(workflowId); @@ -112,8 +111,8 @@ export function useTaskSubmitHandlers({ setHasTitle, setHasDescription, setTaskName, - setRepositoryId, - setBranch, + setRepositories, + setGitHubBranch, setAgentProfileId, setExecutorId, setSelectedWorkflowId, @@ -125,21 +124,21 @@ export function useTaskSubmitHandlers({ buildRepositoriesPayload({ useGitHubUrl, githubUrl, - branch, + githubBranch, githubPrHeadBranch, - repositoryId, - selectedLocalRepo, + repositories, + discoveredRepositories, freshBranch: buildFreshBranchPayload(consentedDirtyFiles), }), - // buildFreshBranchPayload is intentionally a closure over current scope; lint exception kept narrow. + // buildFreshBranchPayload is a closure over current scope; lint exception kept narrow. // eslint-disable-next-line react-hooks/exhaustive-deps [ useGitHubUrl, - repositoryId, - branch, githubUrl, + githubBranch, githubPrHeadBranch, - selectedLocalRepo, + repositories, + discoveredRepositories, isFreshBranchActive, ], ); diff --git a/apps/web/components/task-create-dialog-types.ts b/apps/web/components/task-create-dialog-types.ts index cc59b0a58c..88ddc13e8b 100644 --- a/apps/web/components/task-create-dialog-types.ts +++ b/apps/web/components/task-create-dialog-types.ts @@ -16,6 +16,26 @@ import type { } from "@/components/task-create-dialog-options"; import type { useToast } from "@/components/toast-provider"; +/** + * One repository row in the task-create form. The form tracks every repo + * (whether one or many) as an entry in `repositories[]`. There is no + * "primary" — the backend treats them all equally and uses array order + * for position. Removing the last row clears the form's repo selection. + * + * Exactly one of `repositoryId` (workspace repo) or `localPath` (on-machine + * git folder discovered via the workspace's repo discovery) is set per row. + * `key` is a stable client-side id used as React key and for + * add/update/remove ops; it's not sent to the backend. + */ +export type TaskRepoRow = { + key: string; + /** Workspace repo id, when the user picked from the workspace's repos. */ + repositoryId?: string; + /** On-machine repo path, when the user picked from discovered repos. */ + localPath?: string; + branch: string; +}; + export type StepType = { id: string; title: string; @@ -73,7 +93,6 @@ export type DialogComputedArgs = { workspaceId: string | null; workflowId: string | null; defaultStepId: string | null; - branches: Branch[]; settingsData: { agentsLoaded: boolean; executorsLoaded: boolean }; agentProfiles: AgentProfileOption[]; workspaces: Workspace[]; @@ -89,7 +108,6 @@ export type TaskCreateEffectsArgs = { workflowId: string | null; repositories: Repository[]; repositoriesLoading: boolean; - branches: Branch[]; agentProfiles: AgentProfileOption[]; executors: Executor[]; workspaceDefaults: Workspace | null | undefined; @@ -119,10 +137,19 @@ export type DialogFormState = { /** Computed defaults for current open cycle (includes draft restoration) */ currentDefaults: { name: string; description: string }; descriptionInputRef: import("react").RefObject; - repositoryId: string; - setRepositoryId: (v: string) => void; - branch: string; - setBranch: (v: string) => void; + /** + * Unified list of repos on this task. Each row carries either a workspace + * `repositoryId` or a discovered `localPath`, plus its base `branch`. The + * order is the position the backend sees. There is no "primary" concept. + */ + repositories: TaskRepoRow[]; + setRepositories: (v: TaskRepoRow[]) => void; + addRepository: () => void; + removeRepository: (key: string) => void; + updateRepository: (key: string, patch: Partial) => void; + /** GitHub URL mode: a separate flow that replaces the chip row with a URL input. */ + githubBranch: string; + setGitHubBranch: (v: string) => void; agentProfileId: string; setAgentProfileId: (v: string) => void; executorId: string; @@ -131,14 +158,6 @@ export type DialogFormState = { setExecutorProfileId: (v: string) => void; discoveredRepositories: LocalRepository[]; setDiscoveredRepositories: (v: LocalRepository[]) => void; - discoveredRepoPath: string; - setDiscoveredRepoPath: (v: string) => void; - selectedLocalRepo: LocalRepository | null; - setSelectedLocalRepo: (v: LocalRepository | null) => void; - localBranches: Branch[]; - setLocalBranches: (v: Branch[]) => void; - localBranchesLoading: boolean; - setLocalBranchesLoading: (v: boolean) => void; discoverReposLoading: boolean; setDiscoverReposLoading: (v: boolean) => void; discoverReposLoaded: boolean; @@ -185,12 +204,15 @@ export type SubmitHandlersDeps = { workflowId: string | null; effectiveWorkflowId: string | null; effectiveDefaultStepId: string | null; - repositoryId: string; - selectedLocalRepo: LocalRepository | null; + /** Unified repo list from the form. Empty when in GitHub URL mode. */ + repositories: TaskRepoRow[]; + /** All on-machine discovered repos — used to look up `default_branch` for `localPath` rows. */ + discoveredRepositories: LocalRepository[]; useGitHubUrl: boolean; githubUrl: string; githubPrHeadBranch: string | null; - branch: string; + /** Branch for the GitHub URL flow. Per-row branches live on `repositories[i].branch`. */ + githubBranch: string; agentProfileId: string; executorId: string; executorProfileId: string; @@ -217,8 +239,8 @@ export type SubmitHandlersDeps = { setHasTitle: (v: boolean) => void; setHasDescription: (v: boolean) => void; setTaskName: (v: string) => void; - setRepositoryId: (v: string) => void; - setBranch: (v: string) => void; + setRepositories: (v: TaskRepoRow[]) => void; + setGitHubBranch: (v: string) => void; setAgentProfileId: (v: string) => void; setExecutorId: (v: string) => void; setSelectedWorkflowId: (v: string | null) => void; diff --git a/apps/web/components/task-create-dialog.tsx b/apps/web/components/task-create-dialog.tsx index f527ac25b6..459ca42135 100644 --- a/apps/web/components/task-create-dialog.tsx +++ b/apps/web/components/task-create-dialog.tsx @@ -1,35 +1,100 @@ "use client"; +import { FormEvent, useCallback } from "react"; import type { JiraTicket } from "@/lib/types/jira"; import type { LinearIssue } from "@/lib/types/linear"; import { Dialog, DialogContent, DialogHeader, DialogFooter } from "@kandev/ui/dialog"; +import type { Task, Repository } from "@/lib/types/http"; import type { AgentProfileOption } from "@/lib/state/slices"; -import type { useKeyboardShortcutHandler } from "@/hooks/use-keyboard-shortcut"; +import { SHORTCUTS } from "@/lib/keyboard/constants"; +import { useIsUtilityConfigured } from "@/hooks/use-is-utility-configured"; +import { useKeyboardShortcutHandler } from "@/hooks/use-keyboard-shortcut"; +import { useUtilityAgentGenerator } from "@/hooks/use-utility-agent-generator"; import { TaskCreateDialogFooter } from "@/components/task-create-dialog-footer"; import { DiscardLocalChangesDialog } from "@/components/discard-local-changes-dialog"; import { DialogHeaderContent } from "@/components/task-create-dialog-header"; import { - CreateEditSelectors, SessionSelectors, WorkflowSection, DialogPromptSection, } from "@/components/task-create-dialog-form-body"; -import { useBranchOptions, useAgentProfileOptions } from "@/components/task-create-dialog-options"; +import { useAgentProfileOptions } from "@/components/task-create-dialog-options"; import { - BranchSelector, AgentSelector, ExecutorProfileSelector, + InlineTaskName, } from "@/components/task-create-dialog-selectors"; import { useTaskSubmitHandlers } from "@/components/task-create-dialog-submit"; -import { type DialogFormState } from "@/components/task-create-dialog-state"; +import { CreateModeSelectors } from "@/components/task-create-dialog-create-mode-selectors"; +import { RepoChipsRow } from "@/components/task-create-dialog-repo-chips"; +import { useToast } from "@/components/toast-provider"; import { - useTaskCreateDialogSetup, - type TaskCreateDialogProps, -} from "@/components/task-create-dialog-setup"; + useDialogFormState, + useTaskCreateDialogEffects, + useDialogHandlers, + useSessionRepoName, + useTaskCreateDialogData, + computeIsTaskStarted, + type DialogFormState, + type TaskCreateDialogInitialValues, +} from "@/components/task-create-dialog-state"; + +interface TaskCreateDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + mode?: "create" | "edit" | "session"; + workspaceId: string | null; + workflowId: string | null; + defaultStepId: string | null; + steps: Array<{ + id: string; + title: string; + events?: { + on_enter?: Array<{ type: string; config?: Record }>; + on_turn_complete?: Array<{ type: string; config?: Record }>; + }; + }>; + editingTask?: { + id: string; + title: string; + description?: string; + workflowStepId: string; + state?: Task["state"]; + repositoryId?: string; + } | null; + onSuccess?: ( + task: Task, + mode: "create" | "edit", + meta?: { taskSessionId?: string | null }, + ) => void; + onCreateSession?: (data: { prompt: string; agentProfileId: string; executorId: string }) => void; + initialValues?: TaskCreateDialogInitialValues; + taskId?: string | null; + parentTaskId?: string; + /** + * Pin specific form fields to their initial values (used by feature wrappers + * like Improve Kandev that fix the repo + branch + workflow). The current + * implementation just passes the locks through; the chip row's first repo + * is overwritten on each open. The flags are kept for forward compat with + * locking the editor UI itself in a future pass. + */ + lockedFields?: { repository?: boolean; branch?: boolean; workflow?: boolean }; + /** Optional submit hook used by Improve Kandev to wrap the description. */ + transformDescriptionBeforeSubmit?: (description: string) => Promise | string; + /** Optional override for the description placeholder. */ + descriptionPlaceholder?: string; + /** Optional render slot above the description editor. */ + aboveDescriptionSlot?: React.ReactNode; + /** Optional render slot inside the dialog (between body and footer). */ + extraFormSlot?: React.ReactNode; + /** Optional render slot at the bottom of the dialog footer area. */ + bottomSlot?: React.ReactNode; +} type DialogFormBodyProps = { isSessionMode: boolean; isCreateMode: boolean; + isEditMode: boolean; isTaskStarted: boolean; isPassthroughProfile: boolean; initialDescription: string; @@ -37,11 +102,6 @@ type DialogFormBodyProps = { workspaceId: string | null; onJiraImport?: (ticket: JiraTicket) => void; onLinearImport?: (issue: LinearIssue) => void; - branchOptions: ReturnType; - branchesLoading: boolean; - onRefreshBranches: () => void; - branchesFetchedAt?: string; - branchesFetchError?: string; agentProfileOptions: ReturnType; executorProfileOptions: Array<{ value: string; @@ -57,365 +117,473 @@ type DialogFormBodyProps = { effectiveWorkflowId: string | null; fs: DialogFormState; handleKeyDown: ReturnType; - onBranchChange: (v: string) => void; + onTaskNameChange: (v: string) => void; + onRowRepositoryChange: (key: string, value: string) => void; + onRowBranchChange: (key: string, value: string) => void; onAgentProfileChange: (v: string) => void; onExecutorProfileChange: (v: string) => void; onWorkflowChange: (v: string) => void; - hasRepositorySelection: boolean; - isLocalExecutor: boolean; + onToggleGitHubUrl?: () => void; + onGitHubUrlChange: (v: string) => void; + onToggleFreshBranch: (enabled: boolean) => void; enhance?: { onEnhance: () => void; isLoading: boolean; isConfigured: boolean }; workflowAgentLocked: boolean; - onToggleFreshBranch: (enabled: boolean) => void; - extraFormSlot?: React.ReactNode; - lockedFields?: TaskCreateDialogProps["lockedFields"]; - descriptionPlaceholder?: string; + /** Workspace repositories — driven into the chip row for repo + branch picks. */ + repositories: Repository[]; + /** Computed in the parent: single-row + local executor + not URL mode. */ + freshBranchAvailable: boolean; + /** + * True when the selected executor profile runs locally on the host. Used + * to lock the per-row branch pill (the user's checkout dictates the + * branch for local execution; fresh-branch mode unlocks it). + */ + isLocalExecutor: boolean; + /** Optional render slot above the description editor. */ aboveDescriptionSlot?: React.ReactNode; + /** Optional render slot inside the dialog body (rendered above the chip row). */ + extraFormSlot?: React.ReactNode; + /** Optional render slot at the bottom of the dialog body (above the footer). */ bottomSlot?: React.ReactNode; + /** Optional override for the description placeholder. */ + descriptionPlaceholder?: string; }; -// eslint-disable-next-line max-lines-per-function -- thin pass-through; each section already factored into its own component -function DialogFormBody(p: DialogFormBodyProps) { +function CreateModeBody(props: DialogFormBodyProps) { const { - isSessionMode, isCreateMode, + isEditMode, isTaskStarted, - branchOptions, - branchesLoading, - onRefreshBranches, - branchesFetchedAt, - branchesFetchError, + workspaceId, + onJiraImport, + onLinearImport, agentProfileOptions, executorProfileOptions, agentProfiles, agentProfilesLoading, executorsLoading, isCreatingSession, - workflows, - snapshots, - effectiveWorkflowId, fs, - onBranchChange, + onTaskNameChange, + onRowRepositoryChange, + onRowBranchChange, onAgentProfileChange, onExecutorProfileChange, - onWorkflowChange, - hasRepositorySelection, - isLocalExecutor, - workflowAgentLocked, + onToggleGitHubUrl, + onGitHubUrlChange, onToggleFreshBranch, - lockedFields, - } = p; + workflowAgentLocked, + repositories, + freshBranchAvailable, + isLocalExecutor, + } = props; + const showTaskName = (isCreateMode || isEditMode) && !isTaskStarted; + return ( + <> + + {showTaskName && ( + + )} + + + {props.bottomSlot} + + ); +} + +function SessionModeBody(props: DialogFormBodyProps) { + return ( + <> + + + + ); +} + +function DialogFormBody(props: DialogFormBodyProps) { + const { isSessionMode, isCreateMode, isTaskStarted, workflows, snapshots } = props; return (
- : } + [0]["workflows"]} + snapshots={snapshots as Parameters[0]["snapshots"]} + effectiveWorkflowId={props.effectiveWorkflowId} + onWorkflowChange={props.onWorkflowChange} + agentProfiles={props.agentProfiles} /> - - {!isSessionMode && - renderCreateEditSelectors({ - isTaskStarted, - hasRepositorySelection, - branchOptions, - branchesLoading, - onRefreshBranches, - branchesFetchedAt, - branchesFetchError, - agentProfiles, - agentProfilesLoading, - agentProfileOptions, - isCreatingSession, - executorProfileOptions, - executorsLoading, - isLocalExecutor, - workflowAgentLocked, - fs, - onBranchChange, - onAgentProfileChange, - onExecutorProfileChange, - onToggleFreshBranch, - branchLocked: !!lockedFields?.branch, - })} - {!lockedFields?.workflow && ( - [0]["workflows"]} - snapshots={snapshots as Parameters[0]["snapshots"]} - effectiveWorkflowId={effectiveWorkflowId} - onWorkflowChange={onWorkflowChange} - agentProfiles={agentProfiles} - /> - )} - {isSessionMode && ( - - )} - {p.bottomSlot}
); } -type CreateEditSelectorsRenderArgs = Pick< - DialogFormBodyProps, - | "isTaskStarted" - | "hasRepositorySelection" - | "branchOptions" - | "branchesLoading" - | "onRefreshBranches" - | "branchesFetchedAt" - | "branchesFetchError" - | "agentProfiles" - | "agentProfilesLoading" - | "agentProfileOptions" - | "isCreatingSession" - | "executorProfileOptions" - | "executorsLoading" - | "isLocalExecutor" - | "workflowAgentLocked" - | "fs" - | "onBranchChange" - | "onAgentProfileChange" - | "onExecutorProfileChange" - | "onToggleFreshBranch" -> & { branchLocked?: boolean }; +function useEnhanceForDialog(fs: DialogFormState) { + const isConfigured = useIsUtilityConfigured(); + const { enhancePrompt, isEnhancingPrompt } = useUtilityAgentGenerator({ + sessionId: null, + taskTitle: fs.taskName, + }); + const onEnhance = useCallback(() => { + const current = fs.descriptionInputRef.current?.getValue()?.trim(); + if (!current) return; + enhancePrompt(current, (enhanced) => { + fs.descriptionInputRef.current?.setValue(enhanced); + fs.setHasDescription(true); + }); + }, [enhancePrompt, fs]); + return { onEnhance, isLoading: isEnhancingPrompt, isConfigured }; +} -function renderCreateEditSelectors(args: CreateEditSelectorsRenderArgs) { - const { fs } = args; - return ( - +function useJiraImportHandler(fs: DialogFormState) { + return useCallback( + (ticket: JiraTicket) => { + const title = `[${ticket.key}] ${ticket.summary}`; + fs.setTaskName(title); + fs.setHasTitle(true); + const description = ticket.description?.trim() + ? `${ticket.description}\n\n---\nJira: ${ticket.url}` + : `Jira: ${ticket.url}`; + fs.descriptionInputRef.current?.setValue(description); + fs.setHasDescription(true); + }, + [fs], ); } -type DialogFormProps = { - setup: ReturnType; - workspaceId: string | null; - extraFormSlot?: React.ReactNode; - lockedFields?: TaskCreateDialogProps["lockedFields"]; - descriptionPlaceholder?: string; - aboveDescriptionSlot?: React.ReactNode; - bottomSlot?: React.ReactNode; +function useLinearImportHandler(fs: DialogFormState) { + return useCallback( + (issue: LinearIssue) => { + const title = `[${issue.identifier}] ${issue.title}`; + fs.setTaskName(title); + fs.setHasTitle(true); + const description = issue.description?.trim() + ? `${issue.description}\n\n---\nLinear: ${issue.url}` + : `Linear: ${issue.url}`; + fs.descriptionInputRef.current?.setValue(description); + fs.setHasDescription(true); + }, + [fs], + ); +} + +type SubmitWiringArgs = { + props: TaskCreateDialogProps; + fs: ReturnType; + computed: ReturnType["computed"]; + repositoryLocalPath: string; + isSessionMode: boolean; + isEditMode: boolean; }; -function DialogForm({ - setup, - workspaceId, - extraFormSlot, - lockedFields, - descriptionPlaceholder, - aboveDescriptionSlot, - bottomSlot, -}: DialogFormProps) { - const { fs, isSessionMode, isCreateMode, isTaskStarted, workflows, agentProfiles } = setup; - const { snapshots, branchesLoading, computed, handlers, handleKeyDown } = setup; - const { handleJiraImport, handleLinearImport, refreshBranches, branchesFetchedAt } = setup; - const { branchesFetchError } = setup; - const { handleSubmit, handleUpdateWithoutAgent, handleCreateWithoutAgent } = setup.submitHandlers; - const { handleCreateWithPlanMode, handleCancel } = setup.submitHandlers; - return ( -
- - - - - - ); +function useSubmitHandlersWiring({ + props, + fs, + computed, + repositoryLocalPath, + isSessionMode, + isEditMode, +}: SubmitWiringArgs) { + const { workspaceId, workflowId, editingTask, onSuccess, onCreateSession, onOpenChange } = props; + const { parentTaskId } = props; + const taskId = props.taskId ?? null; + return useTaskSubmitHandlers({ + isSessionMode, + isEditMode, + isPassthroughProfile: computed.isPassthroughProfile, + taskName: fs.taskName, + workspaceId, + workflowId, + effectiveWorkflowId: computed.effectiveWorkflowId, + effectiveDefaultStepId: computed.effectiveDefaultStepId, + repositories: fs.repositories, + discoveredRepositories: fs.discoveredRepositories, + useGitHubUrl: fs.useGitHubUrl, + githubUrl: fs.githubUrl, + githubPrHeadBranch: fs.githubPrHeadBranch, + githubBranch: fs.githubBranch, + agentProfileId: computed.effectiveAgentProfileId, + executorId: fs.executorId, + executorProfileId: fs.executorProfileId, + editingTask, + onSuccess, + onCreateSession, + onOpenChange, + taskId, + parentTaskId, + descriptionInputRef: fs.descriptionInputRef, + setIsCreatingSession: fs.setIsCreatingSession, + setIsCreatingTask: fs.setIsCreatingTask, + setHasTitle: fs.setHasTitle, + setHasDescription: fs.setHasDescription, + setTaskName: fs.setTaskName, + setRepositories: fs.setRepositories, + setGitHubBranch: fs.setGitHubBranch, + setAgentProfileId: fs.setAgentProfileId, + setExecutorId: fs.setExecutorId, + setSelectedWorkflowId: fs.setSelectedWorkflowId, + setFetchedSteps: fs.setFetchedSteps, + clearDraft: fs.clearDraft, + freshBranchEnabled: fs.freshBranchEnabled, + isLocalExecutor: computed.isLocalExecutor, + repositoryLocalPath, + }); +} + +/** + * Resolves the on-disk path for the (single) selected row, used by the + * fresh-branch consent flow. Multi-row tasks hide fresh-branch in the UI, + * so we only need to resolve a path when there is exactly one row. + */ +function resolveSingleRowLocalPath(fs: DialogFormState, repositories: Repository[]): string { + if (fs.repositories.length !== 1) return ""; + const row = fs.repositories[0]; + if (row.localPath) return row.localPath; + if (row.repositoryId) { + return repositories.find((r) => r.id === row.repositoryId)?.local_path ?? ""; + } + return ""; +} + +function useTaskCreateDialogSetup(props: TaskCreateDialogProps) { + const { open, mode = "create", workspaceId, workflowId, defaultStepId } = props; + const { editingTask, initialValues } = props; + const isSessionMode = mode === "session"; + const isEditMode = mode === "edit"; + const isCreateMode = mode === "create"; + const isTaskStarted = computeIsTaskStarted(isEditMode, editingTask); + const fs = useDialogFormState(open, workspaceId, workflowId, initialValues); + const { toast } = useToast(); + const sessionRepoName = useSessionRepoName(isSessionMode); + const { + workflows, + agentProfiles, + executors, + snapshots, + repositories, + repositoriesLoading, + computed, + } = useTaskCreateDialogData(open, workspaceId, workflowId, defaultStepId, fs); + const repositoryLocalPath = resolveSingleRowLocalPath(fs, repositories); + useTaskCreateDialogEffects(fs, { + open, + workspaceId, + workflowId, + repositories, + repositoriesLoading, + agentProfiles, + executors, + workspaceDefaults: computed.workspaceDefaults, + toast, + workflows, + }); + const handlers = useDialogHandlers(fs, repositories); + const submitHandlers = useSubmitHandlersWiring({ + props, + fs, + computed, + repositoryLocalPath, + isSessionMode, + isEditMode, + }); + const handleKeyDown = useKeyboardShortcutHandler(SHORTCUTS.SUBMIT, (event) => { + submitHandlers.handleSubmit(event as unknown as FormEvent); + }); + // Fresh-branch is single-row + local executor + not URL mode. The chip row + // can hold any number of repos; we hide the toggle whenever the question + // ("which repo do we discard local changes in?") becomes ambiguous. + const freshBranchAvailable = + !fs.useGitHubUrl && computed.isLocalExecutor && fs.repositories.length === 1; + return { + fs, + isSessionMode, + isEditMode, + isCreateMode, + isTaskStarted, + sessionRepoName, + workflows, + agentProfiles, + snapshots, + repositories, + repositoriesLoading, + computed, + handlers, + submitHandlers, + handleKeyDown, + freshBranchAvailable, + enhance: useEnhanceForDialog(fs), + handleJiraImport: useJiraImportHandler(fs), + handleLinearImport: useLinearImportHandler(fs), + }; } export function TaskCreateDialog(props: TaskCreateDialogProps) { + const { open, onOpenChange, initialValues, workspaceId } = props; const setup = useTaskCreateDialogSetup(props); - const { fs, computed, handlers } = setup; + const { fs, isSessionMode, isEditMode, isCreateMode, isTaskStarted } = setup; + const { sessionRepoName, workflows, agentProfiles, snapshots, repositories } = setup; + const { computed, handlers, handleKeyDown, freshBranchAvailable } = setup; + const { handleSubmit, handleUpdateWithoutAgent, handleCreateWithoutAgent } = setup.submitHandlers; + const { handleCreateWithPlanMode, handleCancel } = setup.submitHandlers; + const { handleJiraImport, handleLinearImport } = setup; return ( - + - - +
+ + + 0 && fs.repositories.every((r) => !!r.branch) + } + agentProfileId={computed.effectiveAgentProfileId} + workspaceId={workspaceId} + effectiveWorkflowId={computed.effectiveWorkflowId ?? null} + executorHint={computed.executorHint} + onCancel={handleCancel} + onUpdateWithoutAgent={handleUpdateWithoutAgent} + onCreateWithoutAgent={handleCreateWithoutAgent} + onCreateWithPlanMode={handleCreateWithPlanMode} + /> + +
); } -type RenderHeaderProps = { - initialTitle: string | undefined; - workspaceId: string | null | undefined; - isCreateMode: boolean; - isEditMode: boolean; - isTaskStarted: boolean; - sessionRepoName: string | null | undefined; - fs: ReturnType["fs"]; - repositoriesLoading: boolean; - computed: ReturnType["computed"]; - handlers: ReturnType["handlers"]; - repositoryLocked?: boolean; -}; - -function RenderHeader(props: RenderHeaderProps) { - const { fs, computed, handlers } = props; - return ( - - ); -} - function PendingDiscardModal({ pending, }: { diff --git a/apps/web/components/task/changes-panel-file-row.tsx b/apps/web/components/task/changes-panel-file-row.tsx index 8a1d79c999..7a6d6a457b 100644 --- a/apps/web/components/task/changes-panel-file-row.tsx +++ b/apps/web/components/task/changes-panel-file-row.tsx @@ -32,6 +32,8 @@ type ChangedFile = { plus: number | undefined; minus: number | undefined; oldPath: string | undefined; + /** Multi-repo: name of the repo this file lives in. Empty for single-repo. */ + repositoryName?: string; }; type FileRowProps = { @@ -40,9 +42,12 @@ type FileRowProps = { isSelected?: boolean; onSelect?: (path: string, e: React.MouseEvent) => boolean; onOpenDiff: (path: string) => void; - onStage: (path: string) => void; - onUnstage: (path: string) => void; - onDiscard: (path: string) => void; + // Multi-repo: handlers receive the file's repository_name so the per-file + // staging op routes to the right git repo. Same-named files (e.g. README.md + // in two repos) cannot be disambiguated by path alone. + onStage: (path: string, repo?: string) => void; + onUnstage: (path: string, repo?: string) => void; + onDiscard: (path: string, repo?: string) => void; onEditFile: (path: string) => void; }; @@ -83,6 +88,7 @@ export function FileRow({ isPending={isPending} staged={file.staged} path={file.path} + repo={file.repositoryName} onStage={onStage} onUnstage={onUnstage} /> @@ -98,7 +104,12 @@ export function FileRow({
- +
@@ -110,14 +121,16 @@ function StageButton({ isPending, staged, path, + repo, onStage, onUnstage, }: { isPending: boolean; staged: boolean; path: string; - onStage: (path: string) => void; - onUnstage: (path: string) => void; + repo?: string; + onStage: (path: string, repo?: string) => void; + onUnstage: (path: string, repo?: string) => void; }) { if (isPending) { return ( @@ -134,7 +147,7 @@ function StageButton({ className="group/unstage flex-shrink-0 flex items-center justify-center size-4 rounded bg-emerald-500/20 text-emerald-600 hover:bg-rose-500/20 hover:text-rose-600 cursor-pointer" onClick={(e) => { e.stopPropagation(); - onUnstage(path); + onUnstage(path, repo); }} > @@ -149,7 +162,7 @@ function StageButton({ className="flex-shrink-0 flex items-center justify-center size-4 rounded border border-dashed border-muted-foreground/50 text-muted-foreground hover:border-emerald-500 hover:text-emerald-500 hover:bg-emerald-500/10 cursor-pointer" onClick={(e) => { e.stopPropagation(); - onStage(path); + onStage(path, repo); }} > @@ -159,11 +172,13 @@ function StageButton({ function FileRowActions({ path, + repo, onDiscard, onEditFile, }: { path: string; - onDiscard: (path: string) => void; + repo?: string; + onDiscard: (path: string, repo?: string) => void; onEditFile: (path: string) => void; }) { return ( @@ -175,7 +190,7 @@ function FileRowActions({ className="text-muted-foreground hover:text-foreground cursor-pointer" onClick={(e) => { e.stopPropagation(); - onDiscard(path); + onDiscard(path, repo); }} > diff --git a/apps/web/components/task/changes-panel-header.tsx b/apps/web/components/task/changes-panel-header.tsx index 60c07bbcf9..d90f482dc0 100644 --- a/apps/web/components/task/changes-panel-header.tsx +++ b/apps/web/components/task/changes-panel-header.tsx @@ -17,16 +17,76 @@ import { DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, + DropdownMenuLabel, + DropdownMenuSeparator, } from "@kandev/ui/dropdown-menu"; import { PanelHeaderBarSplit } from "./panel-primitives"; +type PerRepoStatus = { + repository_name: string; + branch: string | null; + ahead: number; + behind: number; + hasStaged: boolean; + hasUnstaged: boolean; +}; + +type BranchRow = { repoLabel: string | null; branch: string; baseBranch: string }; + +/** + * Builds per-repo rows for the branch hover card. Returns [] for single-repo + * workspaces (callers fall back to the single-row layout); otherwise one row + * per named repo with that repo's task base_branch (or the workspace-level + * fallback when none was recorded). + */ +function buildBranchRows( + perRepoStatus: PerRepoStatus[], + baseBranchByRepo: Record | undefined, + baseBranchFallback: string, + repoDisplayName: ((name: string) => string | undefined) | undefined, +): BranchRow[] { + const named = perRepoStatus.filter((s) => s.repository_name !== "" && s.branch); + if (named.length <= 1) return []; + return named.map((s) => ({ + repoLabel: repoDisplayName?.(s.repository_name) || s.repository_name, + branch: s.branch ?? "", + baseBranch: baseBranchByRepo?.[s.repository_name] || baseBranchFallback, + })); +} + +function BranchRowView({ repoLabel, branch, baseBranch }: BranchRow) { + return ( +
+ {repoLabel && ( + + {repoLabel} + + )} + + + {branch} + +
+ + {baseBranch} +
+ ); +} + function BranchHoverCard({ displayBranch, baseBranchDisplay, + rows, }: { displayBranch: string; baseBranchDisplay: string; + /** When non-empty, the card renders one row per repo instead of the single + * workspace-level pair. Single-repo workspaces leave this undefined. */ + rows?: BranchRow[]; }) { + const isMulti = rows && rows.length > 0; + const headerLabel = isMulti ? "Your branches:" : "Your code lives in:"; + const trailerLabel = "and will be merged into:"; return ( @@ -40,40 +100,86 @@ function BranchHoverCard({
- Your code lives in: - and will be merged into: -
-
- - - {displayBranch} - -
- - {baseBranchDisplay} + {headerLabel} + {trailerLabel}
+ {isMulti ? ( +
+ {rows!.map((row) => ( + + ))} +
+ ) : ( + + )}
); } +function PullTriggerContent({ + behindCount, + isPulling, + isRebasing, +}: { + behindCount: number; + isPulling: boolean; + isRebasing: boolean; +}) { + const isPullRelated = isPulling || isRebasing; + let label: string; + if (isPulling) label = "Pulling…"; + else if (isRebasing) label = "Rebasing…"; + else label = "Pull"; + return ( + <> + {isPullRelated ? ( + + ) : ( + + )} + {label} + {behindCount > 0 && !isPullRelated && ( + {behindCount} + )} + {!isPullRelated && } + + ); +} + function PullDropdown({ behindCount, isLoading, loadingOperation, - onPull, - onRebase, + repoNames, + perRepoStatus, + onRepoPull, + onRepoRebase, + onRepoMerge, + repoDisplayName, }: { behindCount: number; isLoading: boolean; loadingOperation: string | null; - onPull: () => void; - onRebase: () => void; + /** Always non-empty (single-repo includes the empty-name entry). */ + repoNames: string[]; + perRepoStatus: PerRepoStatus[]; + onRepoPull: (repo: string) => void; + onRepoRebase: (repo: string) => void; + onRepoMerge: (repo: string) => void; + /** Maps a repository_name to its display label. */ + repoDisplayName?: (repositoryName: string) => string | undefined; }) { const isPulling = loadingOperation === "pull"; const isRebasing = loadingOperation === "rebase"; - const isPullRelated = isPulling || isRebasing; + // For single-repo (empty repo entry), the trigger label uses the global + // behindCount; for multi-repo we show the per-repo behinds inside the menu + // labels and the trigger summarises with the max. + const triggerBehind = + perRepoStatus.length > 0 + ? Math.max(behindCount, ...perRepoStatus.map((s) => s.behind)) + : behindCount; return ( @@ -83,67 +189,132 @@ function PullDropdown({ className="h-5 text-[11px] px-1.5 gap-1 cursor-pointer" disabled={isLoading} > - {isPullRelated ? ( - - ) : ( - - )} - {isPulling && "Pulling…"} - {!isPulling && isRebasing && "Rebasing…"} - {!isPulling && !isRebasing && "Pull"} - {behindCount > 0 && !isPullRelated && ( - {behindCount} - )} - {!isPullRelated && } + - - - - Pull - {behindCount > 0 && ( - {behindCount} behind - )} - - - - Rebase - {behindCount > 0 && ( - {behindCount} behind - )} - + + ); } +function PerRepoPullMenu({ + repoNames, + perRepoStatus, + onRepoPull, + onRepoRebase, + onRepoMerge, + repoDisplayName, +}: { + repoNames: string[]; + perRepoStatus: PerRepoStatus[]; + onRepoPull: (repo: string) => void; + onRepoRebase: (repo: string) => void; + onRepoMerge: (repo: string) => void; + repoDisplayName?: (repositoryName: string) => string | undefined; +}) { + const statusByName = new Map(perRepoStatus.map((s) => [s.repository_name, s])); + return ( + <> + {repoNames.map((repo, idx) => { + const s = statusByName.get(repo); + const behind = s?.behind ?? 0; + const label = repoDisplayName?.(repo) || repo || "Repository"; + return ( +
+ {idx > 0 && } + + {label} + {behind > 0 && ( + {behind} behind + )} + + onRepoPull(repo)} + className="cursor-pointer text-xs gap-2" + > + + Pull + + onRepoRebase(repo)} + className="cursor-pointer text-xs gap-2" + > + + Rebase + + onRepoMerge(repo)} + className="cursor-pointer text-xs gap-2" + > + + Merge + +
+ ); + })} + + ); +} + export function ChangesPanelHeader({ hasChanges, hasCommits, hasPRFiles, displayBranch, baseBranchDisplay, + baseBranchByRepo, behindCount, isLoading, loadingOperation, onOpenDiffAll, onOpenReview, - onPull, - onRebase, + repoNames, + perRepoStatus, + onRepoPull, + onRepoRebase, + onRepoMerge, + repoDisplayName, }: { hasChanges: boolean; hasCommits: boolean; hasPRFiles?: boolean; displayBranch: string | null; baseBranchDisplay: string; + /** Per-repo merge target, keyed by repository_name. Undefined entries fall + * back to baseBranchDisplay. Empty/missing for single-repo workspaces. */ + baseBranchByRepo?: Record; behindCount: number; isLoading: boolean; loadingOperation: string | null; onOpenDiffAll?: () => void; onOpenReview?: () => void; - onPull: () => void; - onRebase: () => void; + /** Always non-empty (single-repo includes the empty-name entry). */ + repoNames: string[]; + perRepoStatus: PerRepoStatus[]; + onRepoPull: (repo: string) => void; + onRepoRebase: (repo: string) => void; + onRepoMerge: (repo: string) => void; + repoDisplayName?: (repositoryName: string) => string | undefined; }) { + const branchRows = buildBranchRows( + perRepoStatus, + baseBranchByRepo, + baseBranchDisplay, + repoDisplayName, + ); const showDiffReview = hasChanges || hasCommits || !!hasPRFiles; return ( - {displayBranch && ( - + {(displayBranch || branchRows.length > 0) && ( + )} } diff --git a/apps/web/components/task/changes-panel-helpers.test.ts b/apps/web/components/task/changes-panel-helpers.test.ts new file mode 100644 index 0000000000..466a96b4a9 --- /dev/null +++ b/apps/web/components/task/changes-panel-helpers.test.ts @@ -0,0 +1,70 @@ +import { describe, it, expect } from "vitest"; +import { mapPRFilesToChangedFiles } from "./changes-panel-helpers"; +import type { PRDiffFile } from "@/lib/types/github"; + +function diffFile(overrides: Partial): PRDiffFile { + return { + filename: "src/app.ts", + status: "modified", + additions: 1, + deletions: 0, + old_path: undefined, + ...overrides, + } as PRDiffFile; +} + +describe("mapPRFilesToChangedFiles", () => { + it("maps GitHub status strings to FileInfo statuses", () => { + const out = mapPRFilesToChangedFiles([ + diffFile({ filename: "a.ts", status: "added" }), + diffFile({ filename: "b.ts", status: "removed" }), + diffFile({ filename: "c.ts", status: "renamed", old_path: "old/c.ts" }), + diffFile({ filename: "d.ts", status: "modified" }), + // Anything not in the explicit set should fall through to "modified". + diffFile({ filename: "e.ts", status: "weird" as PRDiffFile["status"] }), + ]); + expect(out.map((f) => f.status)).toEqual([ + "added", + "deleted", + "renamed", + "modified", + "modified", + ]); + }); + + it("forwards path, additions, deletions, and old_path", () => { + const [out] = mapPRFilesToChangedFiles([ + diffFile({ + filename: "src/x.ts", + status: "renamed", + additions: 7, + deletions: 3, + old_path: "old/x.ts", + }), + ]); + expect(out.path).toBe("src/x.ts"); + expect(out.plus).toBe(7); + expect(out.minus).toBe(3); + expect(out.oldPath).toBe("old/x.ts"); + }); + + it("stamps repository_name on every row when supplied (multi-repo path)", () => { + const out = mapPRFilesToChangedFiles( + [diffFile({ filename: "a.ts" }), diffFile({ filename: "b.ts" })], + "frontend", + ); + expect(out.every((f) => f.repository_name === "frontend")).toBe(true); + }); + + it("defaults repository_name to '' when caller omits it (single-repo path)", () => { + // Empty string is meaningful: PRFilesGroupedList treats one group with + // empty name as the single-repo case and skips per-repo sub-headers. + const out = mapPRFilesToChangedFiles([diffFile({ filename: "a.ts" })]); + expect(out[0].repository_name).toBe(""); + }); + + it("returns an empty array for empty input", () => { + expect(mapPRFilesToChangedFiles([])).toEqual([]); + expect(mapPRFilesToChangedFiles([], "frontend")).toEqual([]); + }); +}); diff --git a/apps/web/components/task/changes-panel-helpers.ts b/apps/web/components/task/changes-panel-helpers.ts new file mode 100644 index 0000000000..56fb1a4392 --- /dev/null +++ b/apps/web/components/task/changes-panel-helpers.ts @@ -0,0 +1,205 @@ +"use client"; + +import { hashDiff, normalizeDiffContent } from "@/components/review/types"; +import type { FileInfo } from "@/lib/state/store"; +import type { PRDiffFile } from "@/lib/types/github"; +import type { PRChangedFile } from "./changes-panel-timeline"; + +export type ChangedFile = { + path: string; + status: FileInfo["status"]; + staged: boolean; + plus: number | undefined; + minus: number | undefined; + oldPath: string | undefined; + /** Repository this file belongs to in multi-repo workspaces; empty for single-repo. */ + repositoryName?: string; +}; + +export function mapToChangedFiles(files: FileInfo[]): ChangedFile[] { + return files.map((file) => ({ + path: file.path, + status: file.status, + staged: file.staged, + plus: file.additions, + minus: file.deletions, + oldPath: file.old_path, + repositoryName: file.repository_name, + })); +} + +type CumulativeDiffFiles = Record< + string, + { diff?: string; status?: string; additions?: number; deletions?: number } +>; + +function collectReviewPaths( + uncommittedFiles: FileInfo[], + cumulativeDiffFiles: CumulativeDiffFiles | undefined, + prFiles?: PRDiffFile[], +): Set { + const paths = new Set(); + for (const file of uncommittedFiles) { + if (file.diff && normalizeDiffContent(file.diff)) paths.add(file.path); + } + if (cumulativeDiffFiles) { + for (const [path, file] of Object.entries(cumulativeDiffFiles)) { + if (!paths.has(path) && file.diff && normalizeDiffContent(file.diff)) paths.add(path); + } + } + if (prFiles) { + for (const file of prFiles) { + if (!paths.has(file.filename) && file.patch) paths.add(file.filename); + } + } + return paths; +} + +function getDiffForPath( + path: string, + uncommittedFiles: FileInfo[], + cumulativeDiffFiles: CumulativeDiffFiles | undefined, + prFiles?: PRDiffFile[], +): string { + const uncommitted = uncommittedFiles.find((f) => f.path === path); + if (uncommitted?.diff) return normalizeDiffContent(uncommitted.diff); + const cumDiff = cumulativeDiffFiles?.[path]?.diff; + if (cumDiff) return normalizeDiffContent(cumDiff); + if (prFiles) { + const prFile = prFiles.find((f) => f.filename === path); + if (prFile?.patch) return normalizeDiffContent(prFile.patch); + } + return ""; +} + +export function computeReviewProgress( + uncommittedFiles: FileInfo[], + cumulativeDiff: { files?: CumulativeDiffFiles } | null, + reviews: Map, + prFiles?: PRDiffFile[], +) { + const cumulativeDiffFiles = cumulativeDiff?.files; + const paths = collectReviewPaths(uncommittedFiles, cumulativeDiffFiles, prFiles); + let reviewed = 0; + for (const path of paths) { + const state = reviews.get(path); + if (!state?.reviewed) continue; + const diffContent = getDiffForPath(path, uncommittedFiles, cumulativeDiffFiles, prFiles); + if (diffContent && state.diffHash && state.diffHash !== hashDiff(diffContent)) continue; + reviewed++; + } + return { reviewedCount: reviewed, totalFileCount: paths.size }; +} + +export function computeStagedStats(stagedFiles: FileInfo[]) { + const adds = stagedFiles.reduce((sum, f) => sum + (f.additions || 0), 0); + const dels = stagedFiles.reduce((sum, f) => sum + (f.deletions || 0), 0); + return { stagedFileCount: stagedFiles.length, stagedAdditions: adds, stagedDeletions: dels }; +} + +export function mapPRFilesToChangedFiles( + files: PRDiffFile[], + repositoryName?: string, +): PRChangedFile[] { + return files.map((file) => { + let status: FileInfo["status"]; + switch (file.status) { + case "added": + status = "added"; + break; + case "removed": + status = "deleted"; + break; + case "renamed": + status = "renamed"; + break; + default: + status = "modified"; + } + return { + path: file.filename, + status, + plus: file.additions, + minus: file.deletions, + oldPath: file.old_path, + repository_name: repositoryName ?? "", + }; + }); +} + +export function filterUnpushedCommits( + localCommits: T[], + prCommits: { sha: string }[], +): T[] { + if (prCommits.length === 0) return localCommits; + return localCommits.filter( + (c) => + !prCommits.some((pr) => pr.sha.startsWith(c.commit_sha) || c.commit_sha.startsWith(pr.sha)), + ); +} + +type MergedCommit = { + commit_sha: string; + commit_message: string; + insertions: number; + deletions: number; + pushed: boolean; + /** Multi-repo: name of the repo this commit was made in. Empty for single-repo. */ + repository_name?: string; +}; + +/** + * Merge local session commits and PR commits into a single list. Local + * commits matched to a PR commit are marked pushed; unmatched are unpushed. + * PR-only commits (e.g. from other contributors) are appended as pushed. + * Order: unpushed first, then pushed. + */ +export function mergeCommits( + localCommits: { + commit_sha: string; + commit_message: string; + insertions: number; + deletions: number; + /** Multi-repo: name of the repo this commit was made in. */ + repository_name?: string; + }[], + prCommits: { sha: string; message: string; additions: number; deletions: number }[], +): MergedCommit[] { + const matchesPR = (localSha: string) => + prCommits.some((pr) => pr.sha.startsWith(localSha) || localSha.startsWith(pr.sha)); + const unpushed: MergedCommit[] = []; + const pushed: MergedCommit[] = []; + const matchedPRShas = new Set(); + for (const c of localCommits) { + if (matchesPR(c.commit_sha)) { + pushed.push({ ...c, pushed: true }); + for (const pr of prCommits) { + if (pr.sha.startsWith(c.commit_sha) || c.commit_sha.startsWith(pr.sha)) { + matchedPRShas.add(pr.sha); + } + } + } else { + unpushed.push({ ...c, pushed: false }); + } + } + for (const pr of prCommits) { + if (!matchedPRShas.has(pr.sha)) { + pushed.push({ + commit_sha: pr.sha, + commit_message: pr.message, + insertions: pr.additions, + deletions: pr.deletions, + pushed: true, + }); + } + } + return [...unpushed, ...pushed]; +} + +export function getBaseBranchDisplay(baseBranch: string | undefined): string { + return baseBranch ? baseBranch.replace(/^origin\//, "") : "main"; +} + +export function isMultiRepoCommits(commits: { repository_name?: string }[]): boolean { + return commits.some((c) => !!c.repository_name); +} diff --git a/apps/web/components/task/changes-panel-hooks.ts b/apps/web/components/task/changes-panel-hooks.ts index 8eb0ffd3cb..aee3f44a80 100644 --- a/apps/web/components/task/changes-panel-hooks.ts +++ b/apps/web/components/task/changes-panel-hooks.ts @@ -1,33 +1,78 @@ "use client"; import { useState, useCallback } from "react"; -import type { GitOperationResult, PRCreateResult } from "@/hooks/use-git-operations"; import type { useToast } from "@/components/toast-provider"; +import type { SessionGit, PerRepoOperationResult } from "@/hooks/domains/session/use-session-git"; -// Accepts both useGitOperations and SessionGit -interface GitOps { - pull: (rebase?: boolean) => Promise; - push: (options?: { force?: boolean; setUpstream?: boolean }) => Promise; - rebase: (baseBranch: string) => Promise; - commit: (message: string, stageAll?: boolean, amend?: boolean) => Promise; - stage: (paths?: string[]) => Promise; - unstage: (paths?: string[]) => Promise; - discard: (paths?: string[]) => Promise; - revertCommit: (commitSHA: string) => Promise; - reset: (commitSHA: string, mode: "soft" | "hard") => Promise; - createPR: ( - title: string, - body: string, - baseBranch?: string, - draft?: boolean, - ) => Promise; - isLoading: boolean; -} +// Bug 7: drop the local GitOps shape — `SessionGit` is the single source of +// truth for all the methods this module needs (pull, push, rebase, merge, +// commit, stage, unstage, discard, revertCommit, reset, createPR, isLoading). +// Callers pass the SessionGit returned by `useSessionGit` directly. +type GitOps = Pick< + SessionGit, + | "pull" + | "push" + | "rebase" + | "merge" + | "commit" + | "stage" + | "unstage" + | "discard" + | "revertCommit" + | "reset" + | "createPR" + | "isLoading" +>; type Toast = ReturnType["toast"]; -type GitOperationFn = ( - op: () => Promise<{ success: boolean; output: string; error?: string }>, - name: string, -) => Promise; +type GitOperationResultLike = { + success: boolean; + output: string; + error?: string; + per_repo?: PerRepoOperationResult[]; +}; +type GitOperationFn = (op: () => Promise, name: string) => Promise; + +/** + * Builds the toast description for a fan-out result. When `per_repo` is + * present, summarise per-repo successes/failures instead of returning the + * raw output (which was just the last repo's text and hid partial-success). + */ +function describePerRepo( + perRepo: PerRepoOperationResult[], + operationName: string, +): { title: string; description: string; variant: "success" | "error" } { + const succeeded = perRepo.filter((r) => r.success); + const failed = perRepo.filter((r) => !r.success); + const succeededNames = succeeded.map((r) => r.repository_name).join(", "); + const failedSummary = failed + .map((r) => `${r.repository_name}: ${r.error || "unknown error"}`) + .join("; "); + if (failed.length === 0) { + return { + title: `${operationName} successful`, + description: `${operationName} succeeded in ${succeeded.length} repos: ${succeededNames}`, + variant: "success", + }; + } + if (succeeded.length === 0) { + return { + title: `${operationName} failed`, + description: `Failed in ${failed.length} repos — ${failedSummary}`, + variant: "error", + }; + } + // Partial success: surface as error so the user notices, but include the + // succeeded list in the description so they don't retry the whole op. + return { + title: `${operationName} partially succeeded`, + description: `${operationName} succeeded in ${succeeded.length} of ${perRepo.length} repos (${succeededNames}); failed in ${failedSummary}`, + variant: "error", + }; +} + +function labelWithRepo(label: string, repo: string | undefined): string { + return repo ? `${label} (${repo})` : label; +} export function useChangesGitHandlers( gitOps: GitOps, @@ -35,12 +80,17 @@ export function useChangesGitHandlers( baseBranch: string | undefined, ) { const handleGitOperation = useCallback( - async ( - operation: () => Promise<{ success: boolean; output: string; error?: string }>, - operationName: string, - ) => { + async (operation: () => Promise, operationName: string) => { try { const result = await operation(); + // Bug 2: when the underlying op fanned out across multiple repos, + // describe the per-repo breakdown instead of the legacy flat + // success/error so partial successes are visible. + if (result.per_repo && result.per_repo.length > 1) { + const { title, description, variant } = describePerRepo(result.per_repo, operationName); + toast({ title, description, variant }); + return; + } const variant = result.success ? "success" : "error"; const title = result.success ? `${operationName} successful` : `${operationName} failed`; const description = result.success @@ -58,22 +108,44 @@ export function useChangesGitHandlers( [toast], ); - const handlePull = useCallback(() => { - handleGitOperation(() => gitOps.pull(), "Pull"); - }, [handleGitOperation, gitOps]); - const handleRebase = useCallback(() => { - const targetBranch = baseBranch?.replace(/^origin\//, "") || "main"; - handleGitOperation(() => gitOps.rebase(targetBranch), "Rebase"); - }, [handleGitOperation, gitOps, baseBranch]); - const handlePush = useCallback(() => { - handleGitOperation(() => gitOps.push(), "Push"); - }, [handleGitOperation, gitOps]); - const handleForcePush = useCallback(() => { - handleGitOperation(() => gitOps.push({ force: true }), "Force push"); - }, [handleGitOperation, gitOps]); + const handlePull = useCallback( + (repo?: string) => { + handleGitOperation(() => gitOps.pull(false, repo), labelWithRepo("Pull", repo)); + }, + [handleGitOperation, gitOps], + ); + const handleRebase = useCallback( + (repo?: string) => { + const targetBranch = baseBranch?.replace(/^origin\//, "") || "main"; + handleGitOperation(() => gitOps.rebase(targetBranch, repo), labelWithRepo("Rebase", repo)); + }, + [handleGitOperation, gitOps, baseBranch], + ); + const handleMerge = useCallback( + (repo?: string) => { + const targetBranch = baseBranch?.replace(/^origin\//, "") || "main"; + handleGitOperation(() => gitOps.merge(targetBranch, repo), labelWithRepo("Merge", repo)); + }, + [handleGitOperation, gitOps, baseBranch], + ); + const handlePush = useCallback( + (repo?: string) => { + handleGitOperation(() => gitOps.push(undefined, repo), labelWithRepo("Push", repo)); + }, + [handleGitOperation, gitOps], + ); + const handleForcePush = useCallback( + (repo?: string) => { + handleGitOperation( + () => gitOps.push({ force: true }, repo), + labelWithRepo("Force push", repo), + ); + }, + [handleGitOperation, gitOps], + ); const handleRevertCommit = useCallback( - (sha: string) => { - handleGitOperation(() => gitOps.revertCommit(sha), "Revert commit"); + (sha: string, repo?: string) => { + handleGitOperation(() => gitOps.revertCommit(sha, repo), "Revert commit"); }, [handleGitOperation, gitOps], ); @@ -82,6 +154,7 @@ export function useChangesGitHandlers( handleGitOperation, handlePull, handleRebase, + handleMerge, handlePush, handleForcePush, handleRevertCommit, @@ -96,22 +169,27 @@ function useChangesDiscardAmendHandlers( const [showDiscardDialog, setShowDiscardDialog] = useState(false); const [fileToDiscard, setFileToDiscard] = useState(null); const [filesToDiscard, setFilesToDiscard] = useState(null); + // Multi-repo: remember the clicked file's repo so the discard op routes to + // the right git repo. Path alone is ambiguous when two repos share a name. + const [repoToDiscard, setRepoToDiscard] = useState(undefined); - const handleDiscardClick = useCallback((filePath: string) => { + const handleDiscardClick = useCallback((filePath: string, repo?: string) => { setFileToDiscard(filePath); + setRepoToDiscard(repo); setFilesToDiscard(null); setShowDiscardDialog(true); }, []); const handleBulkDiscardClick = useCallback((paths: string[]) => { setFilesToDiscard(paths); setFileToDiscard(null); + setRepoToDiscard(undefined); setShowDiscardDialog(true); }, []); const handleDiscardConfirm = useCallback(async () => { const paths = filesToDiscard ?? (fileToDiscard ? [fileToDiscard] : null); if (!paths) return; try { - const result = await gitOps.discard(paths); + const result = await gitOps.discard(paths, repoToDiscard); if (!result.success) toast({ title: "Failed to discard changes", @@ -128,24 +206,34 @@ function useChangesDiscardAmendHandlers( setShowDiscardDialog(false); setFileToDiscard(null); setFilesToDiscard(null); + setRepoToDiscard(undefined); } - }, [fileToDiscard, filesToDiscard, gitOps, toast]); + }, [fileToDiscard, filesToDiscard, repoToDiscard, gitOps, toast]); // Amend dialog state (for editing last commit message directly) const [amendDialogOpen, setAmendDialogOpen] = useState(false); const [amendMessage, setAmendMessage] = useState(""); + // Multi-repo: capture the commit's repo at click time so the amend lands in + // the right git repo. Path/SHA alone can't be disambiguated when each repo + // has its own HEAD. + const [amendRepo, setAmendRepo] = useState(undefined); - const handleOpenAmendDialog = useCallback((currentMessage: string) => { + const handleOpenAmendDialog = useCallback((currentMessage: string, repo?: string) => { setAmendMessage(currentMessage); + setAmendRepo(repo); setAmendDialogOpen(true); }, []); const handleAmend = useCallback(async () => { if (!amendMessage.trim()) return; setAmendDialogOpen(false); - await handleGitOperation(() => gitOps.commit(amendMessage.trim(), false, true), "Amend commit"); + await handleGitOperation( + () => gitOps.commit(amendMessage.trim(), false, true, amendRepo), + "Amend commit", + ); setAmendMessage(""); - }, [amendMessage, handleGitOperation, gitOps]); + setAmendRepo(undefined); + }, [amendMessage, amendRepo, handleGitOperation, gitOps]); return { showDiscardDialog, @@ -168,9 +256,13 @@ function useChangesDiscardAmendHandlers( function useChangesResetHandlers(gitOps: GitOps, handleGitOperation: GitOperationFn) { const [resetDialogOpen, setResetDialogOpen] = useState(false); const [resetCommitSha, setResetCommitSha] = useState(null); + // Multi-repo: capture the commit's repo so reset runs against the right + // git repo. Without it, reset hits the workspace root and fails. + const [resetRepo, setResetRepo] = useState(undefined); - const handleOpenResetDialog = useCallback((sha: string) => { + const handleOpenResetDialog = useCallback((sha: string, repo?: string) => { setResetCommitSha(sha); + setResetRepo(repo); setResetDialogOpen(true); }, []); @@ -179,10 +271,11 @@ function useChangesResetHandlers(gitOps: GitOps, handleGitOperation: GitOperatio if (!resetCommitSha) return; setResetDialogOpen(false); const operationName = mode === "hard" ? "Hard reset" : "Soft reset"; - await handleGitOperation(() => gitOps.reset(resetCommitSha, mode), operationName); + await handleGitOperation(() => gitOps.reset(resetCommitSha, mode, resetRepo), operationName); setResetCommitSha(null); + setResetRepo(undefined); }, - [resetCommitSha, handleGitOperation, gitOps], + [resetCommitSha, resetRepo, handleGitOperation, gitOps], ); return { diff --git a/apps/web/components/task/changes-panel-pr-files.tsx b/apps/web/components/task/changes-panel-pr-files.tsx new file mode 100644 index 0000000000..ff630b019e --- /dev/null +++ b/apps/web/components/task/changes-panel-pr-files.tsx @@ -0,0 +1,126 @@ +"use client"; + +import { useState } from "react"; +import { IconChevronDown, IconChevronRight, IconGitPullRequest } from "@tabler/icons-react"; +import { LineStat } from "@/components/diff-stat"; +import { FileStatusIcon } from "./file-status-icon"; +import { groupByRepositoryName } from "@/lib/group-by-repo"; +import type { PRChangedFile } from "./changes-panel-timeline"; + +type PRFilesSectionContentProps = { + files: PRChangedFile[]; + onOpenDiff: (path: string) => void; + /** Maps a repository_name to a human-readable label for the per-repo header. */ + repoDisplayName?: (repositoryName: string) => string | undefined; +}; + +/** + * Body of the PR Changes section: groups files by `repository_name` so + * multi-repo tasks render one sub-header per repo, mirroring the Commits + * and Changes sections. Single-repo tasks (one group with empty name) + * render the file list flat — the section header is enough. + */ +export function PRFilesGroupedList({ + files, + onOpenDiff, + repoDisplayName, +}: PRFilesSectionContentProps) { + const groups = groupByRepositoryName(files, (f) => f.repository_name); + const showRepoHeaders = groups.length > 1 || (groups[0]?.repositoryName ?? "") !== ""; + return ( +
    + {groups.map((group) => ( + + ))} +
+ ); +} + +function PRFilesRepoGroup({ + repositoryName, + displayName, + files, + showHeader, + onOpenDiff, +}: { + repositoryName: string; + displayName?: string; + files: PRChangedFile[]; + showHeader: boolean; + onOpenDiff: (path: string) => void; +}) { + const [collapsed, setCollapsed] = useState(false); + const label = displayName ?? repositoryName ?? ""; + return ( +
  • + {showHeader && ( + + )} + {!collapsed && ( +
      + {files.map((file) => ( + + ))} +
    + )} +
  • + ); +} + +function PRFileRow({ + file, + onOpenDiff, +}: { + file: PRChangedFile; + onOpenDiff: (path: string) => void; +}) { + const lastSlash = file.path.lastIndexOf("/"); + const folder = lastSlash === -1 ? "" : file.path.slice(0, lastSlash); + const name = lastSlash === -1 ? file.path : file.path.slice(lastSlash + 1); + + return ( +
  • onOpenDiff(file.path)} + > +
    +
    + +
    + +
    +
    + + +
    +
  • + ); +} diff --git a/apps/web/components/task/changes-panel-repo-groups.tsx b/apps/web/components/task/changes-panel-repo-groups.tsx new file mode 100644 index 0000000000..955b0ac715 --- /dev/null +++ b/apps/web/components/task/changes-panel-repo-groups.tsx @@ -0,0 +1,263 @@ +"use client"; + +import { useState } from "react"; +import { + IconChevronDown, + IconChevronRight, + IconCloudUpload, + IconGitPullRequest, +} from "@tabler/icons-react"; +import { Button } from "@kandev/ui/button"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; +import { CommitRow, type CommitItem } from "./commit-row"; +import { groupByRepositoryName } from "@/lib/group-by-repo"; + +type ChangedFile = { + path: string; + status: import("@/lib/state/store").FileInfo["status"]; + staged: boolean; + plus: number | undefined; + minus: number | undefined; + oldPath: string | undefined; + repositoryName?: string; +}; + +export type RepoGroup = ReturnType>[number]; + +/** + * Per-repo group inside a file-list section (Unstaged / Staged). Renders the + * collapsible repo header with optional inline action buttons (Stage all / + * Commit / Unstage all). Action labels and handlers are owned by the parent + * section so the same shape works for both variants. + */ +export function RepoGroupItem({ + group, + collapsed, + onToggle, + renderRow, + primaryLabel, + secondaryLabel, + onRepoAction, + onRepoSecondaryAction, + displayName, +}: { + group: RepoGroup; + collapsed: boolean; + onToggle: () => void; + renderRow: (file: ChangedFile) => React.ReactNode; + primaryLabel: string; + secondaryLabel?: string; + onRepoAction?: (repo: string) => void; + onRepoSecondaryAction?: (repo: string) => void; + /** Optional display label override; defaults to group.repositoryName. */ + displayName?: string; +}) { + const stop = (e: React.MouseEvent) => e.stopPropagation(); + const label = displayName || group.repositoryName || "Repository"; + return ( +
  • +
    + + {(onRepoAction || onRepoSecondaryAction) && ( +
    + {onRepoAction && ( + + )} + {onRepoSecondaryAction && secondaryLabel && ( + + )} +
    + )} +
    + {!collapsed &&
      {group.items.map(renderRow)}
    } +
  • + ); +} + +function CommitsGroupActions({ + repositoryName, + unpushedCount, + aheadCount, + prExists, + canCreatePR, + onRepoPush, + onRepoCreatePR, + stop, +}: { + repositoryName: string; + unpushedCount: number; + aheadCount: number; + prExists: boolean; + canCreatePR: boolean; + onRepoPush?: (repo: string) => void; + onRepoCreatePR?: (repo: string) => void; + stop: (e: React.MouseEvent) => void; +}) { + return ( +
    + {onRepoPush && (unpushedCount > 0 || aheadCount > 0) && ( + + )} + {onRepoCreatePR && ( + + + + + {prExists && A pull request already exists for this task} + + )} +
    + ); +} + +/** + * Per-repo group inside the Commits section. Like {@link RepoGroupItem} but + * for commit rows; surfaces a Push button when the repo has unpushed commits. + */ +export function CommitsRepoGroup({ + repositoryName, + displayName, + groupCommits, + aheadCount = 0, + existingPrUrl, + onOpenCommitDetail, + onAmendCommit, + onRevertCommit, + onResetToCommit, + onRepoPush, + onRepoCreatePR, +}: { + repositoryName: string; + displayName?: string; + groupCommits: CommitItem[]; + aheadCount?: number; + existingPrUrl?: string; + onOpenCommitDetail?: (sha: string, repo?: string) => void; + onAmendCommit?: (currentMessage: string, repo?: string) => void; + onRevertCommit?: (sha: string, repo?: string) => void; + onResetToCommit?: (sha: string, repo?: string) => void; + onRepoPush?: (repo: string) => void; + onRepoCreatePR?: (repo: string) => void; + /** Base branch passed through; reserved for richer per-repo PR UX. */ + baseBranch?: string; +}) { + const [collapsed, setCollapsed] = useState(false); + // Each repo has its own "latest unpushed commit" — revert/amend in this + // group must target THIS repo's newest, not the merged-list newest. + const firstUnpushedInGroup = groupCommits.findIndex((c) => c.pushed !== true); + const unpushedCount = groupCommits.filter((c) => !c.pushed).length; + const stop = (e: React.MouseEvent) => e.stopPropagation(); + const label = displayName || repositoryName || "Repository"; + // Bug 10 acknowledged trade-off: `existingPrUrl` is sourced from a + // workspace-scoped `prByRepo` map keyed only by "" today — the kandev task + // model has one PR per task, not one PR per repo. As a result every per- + // repo group inherits the same PR URL. When the backend grows per-repo PR + // tracking, callers should key `prByRepo` by `repositoryName` and the + // `?? prByRepo[""]` fallback in CommitsSection should be removed so each + // group surfaces only its own PR. Until then, the visual is "PR exists for + // this task" rather than "PR exists for this repo" — accept the imprecision. + const prExists = !!existingPrUrl; + // The PR scope today is workspace-wide (one PR per task). Disable per-repo + // Create PR once a PR exists; the user can update it via push instead. + const canCreatePR = !!onRepoCreatePR && !prExists; + return ( +
  • +
    + + +
    + {!collapsed && ( +
      + {groupCommits.map((commit, index) => ( + + ))} +
    + )} +
  • + ); +} diff --git a/apps/web/components/task/changes-panel-timeline-grouping.test.tsx b/apps/web/components/task/changes-panel-timeline-grouping.test.tsx new file mode 100644 index 0000000000..8d83cfbd3d --- /dev/null +++ b/apps/web/components/task/changes-panel-timeline-grouping.test.tsx @@ -0,0 +1,230 @@ +import { describe, it, expect, vi, afterEach } from "vitest"; +import { render, screen, cleanup, fireEvent, within } from "@testing-library/react"; +import type { ComponentProps } from "react"; + +vi.mock("./changes-panel-file-row", () => ({ + FileRow: ({ file }: { file: { path: string } }) =>
  • {file.path}
  • , + BulkActionBar: () => null, + DefaultActionButtons: () => null, +})); + +vi.mock("./commit-row", () => ({ + CommitRow: ({ commit, isLatest }: { commit: { commit_sha: string }; isLatest: boolean }) => ( +
  • + {commit.commit_sha} +
  • + ), +})); + +vi.mock("@/hooks/use-multi-select", () => ({ + useMultiSelect: () => ({ + selectedPaths: new Set(), + isSelected: () => false, + handleClick: vi.fn(), + clearSelection: vi.fn(), + }), +})); + +import { FileListSection, CommitsSection } from "./changes-panel-timeline"; + +const REPO_HEADER_TID = "changes-repo-header"; +const COMMIT_ROW_TID = "commit-row"; +const COMMITS_SECTION_TOGGLE_TID = "commits-section-collapse-toggle"; + +afterEach(cleanup); + +type Props = ComponentProps; + +const baseProps: Omit = { + pendingStageFiles: new Set(), + onOpenDiff: vi.fn(), + onEditFile: vi.fn(), + onStage: vi.fn(), + onUnstage: vi.fn(), + onDiscard: vi.fn(), +}; + +function file(path: string, repo?: string): Props["files"][number] { + return { + path, + status: "modified", + staged: false, + plus: 1, + minus: 0, + oldPath: undefined, + repositoryName: repo, + }; +} + +describe("FileListSection — multi-repo grouping", () => { + it("renders a single repo header (uniform UI) for files without a repository_name", () => { + // Per-repo headers always render now, including in single-repo / untagged + // mode — the resolver fills in the workspace primary repo name (or a + // neutral "Repository" fallback). This keeps the UX consistent across + // single-repo and multi-repo workspaces and gives us per-repo Stage all / + // Commit / Unstage all buttons in both modes. + render( + undefined} + files={[file("a.ts"), file("b.ts")]} + />, + ); + expect(screen.getAllByTestId(REPO_HEADER_TID)).toHaveLength(1); + expect(screen.getAllByTestId("file-row")).toHaveLength(2); + }); + + it("renders one header per repo when 2+ repos are present", () => { + render( + undefined} + files={[ + file("src/app.tsx", "frontend"), + file("src/api.ts", "frontend"), + file("handlers/task.go", "backend"), + ]} + />, + ); + const headers = screen.getAllByTestId(REPO_HEADER_TID); + expect(headers).toHaveLength(2); + expect(headers[0].textContent).toContain("frontend"); + expect(headers[0].textContent).toContain("2"); + expect(headers[1].textContent).toContain("backend"); + expect(headers[1].textContent).toContain("1"); + }); + + it("shows a header for a single named repo too", () => { + render( + undefined} + files={[file("a.ts", "only-repo")]} + />, + ); + expect(screen.getByTestId(REPO_HEADER_TID).textContent).toContain("only-repo"); + }); + + it("collapses one repo independently when its header is clicked", () => { + render( + undefined} + files={[ + file("src/app.tsx", "frontend"), + file("src/api.ts", "frontend"), + file("handlers/task.go", "backend"), + ]} + />, + ); + + expect(screen.getAllByTestId("file-row")).toHaveLength(3); + + const groups = screen.getAllByTestId("changes-repo-group"); + const frontendGroup = groups.find( + (g) => g.getAttribute("data-repository-name") === "frontend", + )!; + const frontendHeader = within(frontendGroup).getByTestId(REPO_HEADER_TID); + + fireEvent.click(frontendHeader); + + // frontend's two rows hidden, backend's one row still visible + expect(screen.getAllByTestId("file-row")).toHaveLength(1); + expect(frontendHeader.getAttribute("aria-expanded")).toBe("false"); + + fireEvent.click(frontendHeader); + expect(screen.getAllByTestId("file-row")).toHaveLength(3); + expect(frontendHeader.getAttribute("aria-expanded")).toBe("true"); + }); +}); + +describe("CommitsSection", () => { + type CommitProps = ComponentProps; + + function commit(sha: string, message: string, repo?: string): CommitProps["commits"][number] { + return { + commit_sha: sha, + commit_message: message, + insertions: 1, + deletions: 0, + pushed: false, + repository_name: repo, + }; + } + + it("renders the commits section header with a collapse toggle", () => { + render(); + // Section is expanded by default — file changes and commit history are + // both first-class signals in the changes panel. + expect(screen.getByTestId(COMMIT_ROW_TID)).toBeTruthy(); + const toggle = screen.getByTestId(COMMITS_SECTION_TOGGLE_TID); + expect(toggle.getAttribute("aria-expanded")).toBe("true"); + }); + + it("groups commits per repo when 2+ repos are present", () => { + render( + , + ); + const headers = screen.getAllByTestId("commits-repo-header"); + expect(headers).toHaveLength(2); + expect(headers[0].textContent).toContain("frontend"); + expect(headers[0].textContent).toContain("2"); + expect(headers[1].textContent).toContain("backend"); + expect(headers[1].textContent).toContain("1"); + expect(screen.getAllByTestId(COMMIT_ROW_TID)).toHaveLength(3); + }); + + it("renders a single commits-repo header (uniform UI) for untagged commits", () => { + // The Commits section, like the file lists, always groups by repo now. + // For untagged or single-repo commits we get one group with the empty + // repository_name; the header still renders so the per-repo Push / PR + // buttons live in a consistent place across single-repo and multi-repo. + render(); + expect(screen.getAllByTestId("commits-repo-header")).toHaveLength(1); + expect(screen.getAllByTestId(COMMIT_ROW_TID)).toHaveLength(2); + }); + + // Regression: previously isLatest was computed against the merged list, so + // only ONE commit globally was marked latest. Each repo's newest unpushed + // commit must be latest in its own group — otherwise revert/amend buttons + // are absent on every repo except one, and clicking revert via context menu + // hits the backend's "can only revert latest commit" gate. + it("marks each repo's newest unpushed commit as latest (per-repo, not global)", () => { + render( + undefined} + />, + ); + const rows = screen.getAllByTestId(COMMIT_ROW_TID); + const latestByShas = rows + .filter((r) => r.getAttribute("data-is-latest") === "true") + .map((r) => r.getAttribute("data-sha")); + expect(latestByShas.sort()).toEqual(["backend-only", "frontend-new"]); + }); +}); diff --git a/apps/web/components/task/changes-panel-timeline.tsx b/apps/web/components/task/changes-panel-timeline.tsx index 1414ed280f..0d03f86c18 100644 --- a/apps/web/components/task/changes-panel-timeline.tsx +++ b/apps/web/components/task/changes-panel-timeline.tsx @@ -20,10 +20,11 @@ import { } from "@kandev/ui/dropdown-menu"; import { cn } from "@/lib/utils"; import type { FileInfo } from "@/lib/state/store"; -import { LineStat } from "@/components/diff-stat"; -import { FileStatusIcon } from "./file-status-icon"; -import { FileRow, BulkActionBar, DefaultActionButtons } from "./changes-panel-file-row"; -import { CommitRow, type CommitItem } from "./commit-row"; +import { FileRow, BulkActionBar } from "./changes-panel-file-row"; +import { type CommitItem } from "./commit-row"; +import { groupByRepositoryName } from "@/lib/group-by-repo"; +import { CommitsRepoGroup, RepoGroupItem } from "./changes-panel-repo-groups"; +import { PRFilesGroupedList } from "./changes-panel-pr-files"; // --- Timeline visual components --- @@ -34,8 +35,12 @@ type ChangedFile = { plus: number | undefined; minus: number | undefined; oldPath: string | undefined; + /** Repository this file belongs to in multi-repo workspaces; empty for single-repo. */ + repositoryName?: string; }; +// Per-repo grouping is shared with the Review dialog — see @/lib/group-by-repo. + // --- Timeline section dot colors --- const DOT_COLORS = { unstaged: "bg-yellow-500", @@ -57,6 +62,7 @@ function TimelineSection({ isLast, children, collapsible = true, + defaultCollapsed = false, "data-testid": testId, }: { dotColor: string; @@ -66,9 +72,10 @@ function TimelineSection({ isLast?: boolean; children?: React.ReactNode; collapsible?: boolean; + defaultCollapsed?: boolean; "data-testid"?: string; }) { - const [collapsed, setCollapsed] = useState(false); + const [collapsed, setCollapsed] = useState(defaultCollapsed); const canCollapse = collapsible && !!label; return ( @@ -126,12 +133,29 @@ function TimelineSection({ type CommitsSectionProps = { commits: CommitItem[]; isLast: boolean; - onOpenCommitDetail?: (sha: string) => void; - onRevertCommit?: (sha: string) => void; - onAmendCommit?: (currentMessage: string) => void; - onResetToCommit?: (sha: string) => void; + onOpenCommitDetail?: (sha: string, repo?: string) => void; + // Handlers receive the commit's repository_name so amend/revert/reset land + // in the right git repo. The empty string routes to the workspace root for + // single-repo workspaces. + onRevertCommit?: (sha: string, repo?: string) => void; + onAmendCommit?: (currentMessage: string, repo?: string) => void; + onResetToCommit?: (sha: string, repo?: string) => void; + /** Per-repo Push button rendered in each commits group header. */ + onRepoPush?: (repo: string) => void; + /** Per-repo Create PR button rendered in each commits group header. */ + onRepoCreatePR?: (repo: string) => void; + /** Maps a repository_name to its display label (used for the empty single-repo case). */ + repoDisplayName?: (repositoryName: string) => string | undefined; + /** The session's base branch — passed through to the per-repo PR dialog. */ + repoBaseBranch?: string; + /** Per-repo branch / ahead / behind summary; used for the "ahead" indicator. */ + perRepoStatus?: Array<{ repository_name: string; ahead: number }>; + /** Existing PR URL keyed by repository_name; "" key for single-repo. */ + prByRepo?: Record; }; +// Commits grouping shares the helper above with files — see @/lib/group-by-repo. + export function CommitsSection({ commits, isLast, @@ -139,9 +163,18 @@ export function CommitsSection({ onRevertCommit, onAmendCommit, onResetToCommit, + onRepoPush, + onRepoCreatePR, + repoDisplayName, + repoBaseBranch, + perRepoStatus, + prByRepo, }: CommitsSectionProps) { - // Find the first unpushed commit for context menu actions (amend/revert only on latest unpushed) - const firstUnpushedIndex = commits.findIndex((c) => c.pushed !== true); + // Always group by repo. Single-repo shows up as one group with an empty + // repository_name; the group header still renders with a display label and + // per-repo buttons (Push, Create PR) so the UX is uniform across modes. + const groups = groupByRepositoryName(commits, (c) => c.repository_name); + const aheadByRepo = new Map((perRepoStatus ?? []).map((s) => [s.repository_name, s.ahead])); return (
      - {commits.map((commit, index) => ( - ( + ))}
    @@ -181,6 +220,8 @@ type ActionButtonsSectionProps = { canCreatePR: boolean; existingPrUrl?: string; isLast?: boolean; + /** Hide the Push button (per-repo Push is rendered elsewhere in multi-repo). */ + hidePush?: boolean; }; export function ActionButtonsSection({ @@ -194,6 +235,7 @@ export function ActionButtonsSection({ canCreatePR, existingPrUrl, isLast = true, + hidePush = false, }: ActionButtonsSectionProps) { const prExists = !!existingPrUrl; const createPrDisabled = !canCreatePR || prExists || isLoading; @@ -227,48 +269,53 @@ export function ActionButtonsSection({ {prExists && A pull request already exists for this task} - - - - - - - - - - - - Force Push (with lease) - - - - - - {pushTooltip && {pushTooltip}} - + {!hidePush && ( + + + + + + + + + + + + Force Push (with lease) + + + + + + {pushTooltip && {pushTooltip}} + + )}
    ); @@ -289,14 +336,107 @@ type FileListSectionProps = { onSecondaryAction?: () => void; onOpenDiff: (path: string) => void; onEditFile: (path: string) => void; - onStage: (path: string) => void; - onUnstage: (path: string) => void; - onDiscard: (path: string) => void; + // Multi-repo: handlers receive the file's repositoryName so each per-file op + // hits the right git repo. Same-named files across repos collide by path. + onStage: (path: string, repo?: string) => void; + onUnstage: (path: string, repo?: string) => void; + onDiscard: (path: string, repo?: string) => void; onBulkStage?: (paths: string[]) => void; onBulkUnstage?: (paths: string[]) => void; onBulkDiscard?: (paths: string[]) => void; + // Per-repo action handlers shown inline with each repo group header. Always + // rendered, including single-repo (with one entry pre-selected) — the empty + // `repo` argument routes ops to the workspace root in that case. + onRepoAction?: (repo: string) => void; + onRepoSecondaryAction?: (repo: string) => void; + /** Maps a repository_name to its display label; called per group header. */ + repoDisplayName?: (repositoryName: string) => string | undefined; }; +/** + * Renders the body of a file-list section. For multi-repo workspaces it + * groups files by repository under per-repo subheaders; single-repo + * workspaces fall back to a flat list (no header) so the existing UI is + * unchanged. + */ +type FileListBodyProps = { + variant: "unstaged" | "staged"; + files: ChangedFile[]; + pendingStageFiles: Set; + multiSelect: ReturnType; + onKeyDown: (e: React.KeyboardEvent) => void; + onOpenDiff: (path: string) => void; + onEditFile: (path: string) => void; + onStage: (path: string, repo?: string) => void; + onUnstage: (path: string, repo?: string) => void; + onDiscard: (path: string, repo?: string) => void; + /** Per-repo group header actions; primary = Stage all (unstaged) / Commit (staged). */ + onRepoAction?: (repo: string) => void; + onRepoSecondaryAction?: (repo: string) => void; + primaryLabel: string; + secondaryLabel?: string; + /** Maps a repository_name to its display label (e.g. "" → workspace primary repo name). */ + repoDisplayName?: (repositoryName: string) => string | undefined; +}; + +function FileListBody(props: FileListBodyProps) { + const { variant, files, pendingStageFiles, multiSelect } = props; + const groups = useMemo(() => groupByRepositoryName(files, (f) => f.repositoryName), [files]); + // Per-repo collapsed state: keyed by repositoryName. Default expanded; + // setting an entry to true collapses that group. Persists across re-renders + // for the lifetime of this section instance (resets on unmount). + const [collapsedRepos, setCollapsedRepos] = useState>(() => new Set()); + const toggleRepo = useCallback((name: string) => { + setCollapsedRepos((prev) => { + const next = new Set(prev); + if (next.has(name)) next.delete(name); + else next.add(name); + return next; + }); + }, []); + + const renderRow = (file: ChangedFile) => ( + + ); + + return ( +
    +
      + {groups.map((group) => ( + toggleRepo(group.repositoryName)} + renderRow={renderRow} + primaryLabel={props.primaryLabel} + secondaryLabel={props.secondaryLabel} + onRepoAction={props.onRepoAction} + onRepoSecondaryAction={props.onRepoSecondaryAction} + displayName={props.repoDisplayName?.(group.repositoryName)} + /> + ))} +
    +
    + ); +} + export function FileListSection(props: FileListSectionProps) { const { variant, @@ -315,7 +455,9 @@ export function FileListSection(props: FileListSectionProps) { const filePaths = useMemo(() => files.map((f) => f.path), [files]); const multiSelect = useMultiSelect({ items: filePaths }); const hasSelection = multiSelect.selectedPaths.size > 0; - + // Multi-repo when any file has a repositoryName. Per-repo group headers + // own the action buttons in this case; the bottom-of-section global + // buttons would be redundant, so we skip them. const handleKeyDown = useCallback( (e: React.KeyboardEvent) => { if (e.key === "Escape" && hasSelection) multiSelect.clearSelection(); @@ -332,51 +474,34 @@ export function FileListSection(props: FileListSectionProps) { data-testid={`${variant}-files-section`} > {files.length > 0 && ( -
    -
      - {files.map((file) => ( - - ))} -
    -
    + )} - {files.length > 0 && ( + {files.length > 0 && hasSelection && (
    - {hasSelection ? ( - - ) : ( - - )} +
    )} @@ -391,15 +516,29 @@ export type PRChangedFile = { plus: number | undefined; minus: number | undefined; oldPath: string | undefined; + /** + * The repository the file belongs to. Empty string for single-repo tasks + * (the section header alone is enough). Multi-repo tasks stamp this so + * `PRFilesSection` can group rows under per-repo subheaders, mirroring + * how the Commits and Changes sections render. + */ + repository_name?: string; }; type PRFilesSectionProps = { files: PRChangedFile[]; isLast: boolean; onOpenDiff: (path: string) => void; + /** Maps a repository_name to a human-readable label (used for the per-repo header). */ + repoDisplayName?: (repositoryName: string) => string | undefined; }; -export function PRFilesSection({ files, isLast, onOpenDiff }: PRFilesSectionProps) { +export function PRFilesSection({ + files, + isLast, + onOpenDiff, + repoDisplayName, +}: PRFilesSectionProps) { return ( {files.length > 0 && ( -
      - {files.map((file) => ( - - ))} -
    + )}
    ); } -function PRFileRow({ - file, - onOpenDiff, -}: { - file: PRChangedFile; - onOpenDiff: (path: string) => void; -}) { - const lastSlash = file.path.lastIndexOf("/"); - const folder = lastSlash === -1 ? "" : file.path.slice(0, lastSlash); - const name = lastSlash === -1 ? file.path : file.path.slice(lastSlash + 1); - - return ( -
  • onOpenDiff(file.path)} - > -
    -
    - -
    - -
    -
    - - -
    -
  • - ); -} - // --- Review progress bar --- type ReviewProgressBarProps = { diff --git a/apps/web/components/task/changes-panel.tsx b/apps/web/components/task/changes-panel.tsx index c31344b422..13e2491347 100644 --- a/apps/web/components/task/changes-panel.tsx +++ b/apps/web/components/task/changes-panel.tsx @@ -6,8 +6,6 @@ import { useAppStore } from "@/components/state-provider"; import { useSessionGit } from "@/hooks/domains/session/use-session-git"; import { useSessionFileReviews } from "@/hooks/use-session-file-reviews"; import { useEnvironmentSessionId } from "@/hooks/use-environment-session-id"; -import { hashDiff, normalizeDiffContent } from "@/components/review/types"; -import type { FileInfo } from "@/lib/state/store"; import { useToast } from "@/components/toast-provider"; import { useIsTaskArchived, ArchivedPanelPlaceholder } from "./task-archived-context"; import { DiscardDialog, AmendDialog, ResetDialog } from "./changes-panel-dialogs"; @@ -16,215 +14,38 @@ import { ChangesPanelHeader } from "./changes-panel-header"; import { FileListSection, CommitsSection, - ActionButtonsSection, ReviewProgressBar, PRFilesSection, } from "./changes-panel-timeline"; import type { PRChangedFile } from "./changes-panel-timeline"; -import { useChangesGitHandlers, useChangesDialogHandlers } from "./changes-panel-hooks"; -import { useActiveTaskPR } from "@/hooks/domains/github/use-task-pr"; -import { usePRDiff } from "@/hooks/domains/github/use-pr-diff"; -import { usePRCommits } from "@/hooks/domains/github/use-pr-commits"; import type { PRDiffFile } from "@/lib/types/github"; +import { useChangesGitHandlers, useChangesDialogHandlers } from "./changes-panel-hooks"; +import { useRepoDisplayName } from "@/hooks/domains/session/use-repo-display-name"; +import { useBaseBranchByRepo } from "@/hooks/domains/session/use-base-branch-by-repo"; +import { useActiveTaskPR } from "@/hooks/domains/github/use-task-pr"; +import { useActiveTaskPRsWithFiles } from "@/hooks/domains/github/use-active-task-pr-files"; +import { usePRCommits } from "@/hooks/domains/github/use-pr-commits"; +import { + type ChangedFile, + computeReviewProgress, + computeStagedStats, + filterUnpushedCommits, + getBaseBranchDisplay, + mapPRFilesToChangedFiles, + mapToChangedFiles, + mergeCommits, +} from "./changes-panel-helpers"; + +export { filterUnpushedCommits, mergeCommits }; type ChangesPanelProps = { onOpenDiffFile: (path: string) => void; onEditFile: (path: string) => void; - onOpenCommitDetail?: (sha: string) => void; + onOpenCommitDetail?: (sha: string, repo?: string) => void; onOpenDiffAll?: () => void; onOpenReview?: () => void; }; -// Maps FileInfo (store) to the display format expected by FileListSection -type ChangedFile = { - path: string; - status: FileInfo["status"]; - staged: boolean; - plus: number | undefined; - minus: number | undefined; - oldPath: string | undefined; -}; - -function mapToChangedFiles(files: FileInfo[]): ChangedFile[] { - return files.map((file) => ({ - path: file.path, - status: file.status, - staged: file.staged, - plus: file.additions, - minus: file.deletions, - oldPath: file.old_path, - })); -} - -type CumulativeDiffFiles = Record< - string, - { diff?: string; status?: string; additions?: number; deletions?: number } ->; - -function collectReviewPaths( - uncommittedFiles: FileInfo[], - cumulativeDiffFiles: CumulativeDiffFiles | undefined, - prFiles?: PRDiffFile[], -): Set { - const paths = new Set(); - for (const file of uncommittedFiles) { - if (file.diff && normalizeDiffContent(file.diff)) paths.add(file.path); - } - if (cumulativeDiffFiles) { - for (const [path, file] of Object.entries(cumulativeDiffFiles)) { - if (!paths.has(path) && file.diff && normalizeDiffContent(file.diff)) paths.add(path); - } - } - if (prFiles) { - for (const file of prFiles) { - if (!paths.has(file.filename) && file.patch) paths.add(file.filename); - } - } - return paths; -} - -function getDiffForPath( - path: string, - uncommittedFiles: FileInfo[], - cumulativeDiffFiles: CumulativeDiffFiles | undefined, - prFiles?: PRDiffFile[], -): string { - const uncommitted = uncommittedFiles.find((f) => f.path === path); - if (uncommitted?.diff) return normalizeDiffContent(uncommitted.diff); - const cumDiff = cumulativeDiffFiles?.[path]?.diff; - if (cumDiff) return normalizeDiffContent(cumDiff); - if (prFiles) { - const prFile = prFiles.find((f) => f.filename === path); - if (prFile?.patch) return normalizeDiffContent(prFile.patch); - } - return ""; -} - -function computeReviewProgress( - uncommittedFiles: FileInfo[], - cumulativeDiff: { files?: CumulativeDiffFiles } | null, - reviews: Map, - prFiles?: PRDiffFile[], -) { - const cumulativeDiffFiles = cumulativeDiff?.files; - const paths = collectReviewPaths(uncommittedFiles, cumulativeDiffFiles, prFiles); - let reviewed = 0; - for (const path of paths) { - const state = reviews.get(path); - if (!state?.reviewed) continue; - const diffContent = getDiffForPath(path, uncommittedFiles, cumulativeDiffFiles, prFiles); - if (diffContent && state.diffHash && state.diffHash !== hashDiff(diffContent)) continue; - reviewed++; - } - return { reviewedCount: reviewed, totalFileCount: paths.size }; -} - -function computeStagedStats(stagedFiles: FileInfo[]) { - const adds = stagedFiles.reduce((sum, f) => sum + (f.additions || 0), 0); - const dels = stagedFiles.reduce((sum, f) => sum + (f.deletions || 0), 0); - return { stagedFileCount: stagedFiles.length, stagedAdditions: adds, stagedDeletions: dels }; -} - -function mapPRFilesToChangedFiles(files: PRDiffFile[]): PRChangedFile[] { - return files.map((file) => { - let status: FileInfo["status"]; - switch (file.status) { - case "added": - status = "added"; - break; - case "removed": - status = "deleted"; - break; - case "renamed": - status = "renamed"; - break; - default: - status = "modified"; - } - return { - path: file.filename, - status, - plus: file.additions, - minus: file.deletions, - oldPath: file.old_path, - }; - }); -} - -export function filterUnpushedCommits( - localCommits: T[], - prCommits: { sha: string }[], -): T[] { - if (prCommits.length === 0) return localCommits; - return localCommits.filter( - (c) => - !prCommits.some((pr) => pr.sha.startsWith(c.commit_sha) || c.commit_sha.startsWith(pr.sha)), - ); -} - -type MergedCommit = { - commit_sha: string; - commit_message: string; - insertions: number; - deletions: number; - pushed: boolean; -}; - -/** - * Merge local session commits and PR commits into a single list. - * Local commits matched to a PR commit are marked pushed; unmatched are unpushed. - * PR-only commits (e.g. from other contributors) are appended as pushed. - * Order: unpushed first, then pushed. - */ -export function mergeCommits( - localCommits: { - commit_sha: string; - commit_message: string; - insertions: number; - deletions: number; - }[], - prCommits: { sha: string; message: string; additions: number; deletions: number }[], -): MergedCommit[] { - const matchesPR = (localSha: string) => - prCommits.some((pr) => pr.sha.startsWith(localSha) || localSha.startsWith(pr.sha)); - - const unpushed: MergedCommit[] = []; - const pushed: MergedCommit[] = []; - const matchedPRShas = new Set(); - - for (const c of localCommits) { - if (matchesPR(c.commit_sha)) { - pushed.push({ ...c, pushed: true }); - for (const pr of prCommits) { - if (pr.sha.startsWith(c.commit_sha) || c.commit_sha.startsWith(pr.sha)) { - matchedPRShas.add(pr.sha); - } - } - } else { - unpushed.push({ ...c, pushed: false }); - } - } - - // Add PR-only commits (not matched to any local commit) - for (const pr of prCommits) { - if (!matchedPRShas.has(pr.sha)) { - pushed.push({ - commit_sha: pr.sha, - commit_message: pr.message, - insertions: pr.additions, - deletions: pr.deletions, - pushed: true, - }); - } - } - - return [...unpushed, ...pushed]; -} - -function getBaseBranchDisplay(baseBranch: string | undefined): string { - return baseBranch ? baseBranch.replace(/^origin\//, "") : "main"; -} - function useChangesPanelStoreData() { const activeTaskId = useAppStore((state) => state.tasks.activeTaskId); // Use environment-stable sessionId so git hooks (commits, cumulative diff) @@ -240,7 +61,9 @@ function useChangesPanelStoreData() { const existingPrUrl = useAppStore((state) => { const taskId = state.tasks.activeTaskId; if (!taskId) return undefined; - return state.taskPRs.byTaskId[taskId]?.pr_url ?? undefined; + // Single-PR consumer: surface the primary PR's URL. Multi-repo cases get + // per-repo PR URLs via the timeline's prByRepo map elsewhere. + return state.taskPRs.byTaskId[taskId]?.[0]?.pr_url ?? undefined; }); return { activeTaskId, activeSessionId, taskTitle, baseBranch, existingPrUrl }; } @@ -284,13 +107,19 @@ type ChangesPanelBodyProps = { dialogs: DialogsType; onOpenDiffFile: (path: string) => void; onEditFile: (path: string) => void; - onOpenCommitDetail?: (sha: string) => void; + onOpenCommitDetail?: (sha: string, repo?: string) => void; onOpenReview?: () => void; - onRevertCommit?: (sha: string) => void; + // Multi-repo: handlers carry the commit's repository_name so revert/amend/ + // reset target the right git repo. Without it the op runs at the workspace + // root and fails ("can only revert latest commit") on multi-repo tasks. + onRevertCommit?: (sha: string, repo?: string) => void; onStageAll: () => void; onUnstageAll: () => void; - onStage: (path: string) => Promise; - onUnstage: (path: string) => Promise; + // Multi-repo: callers pass the file's repository_name so the agentctl op + // routes to the right repo. Same-named files across repos can't be + // disambiguated by path alone. + onStage: (path: string, repo?: string) => Promise; + onUnstage: (path: string, repo?: string) => Promise; onBulkStage: (paths: string[]) => void; onBulkUnstage: (paths: string[]) => void; onBulkDiscard: (paths: string[]) => void; @@ -299,6 +128,20 @@ type ChangesPanelBodyProps = { stagedFileCount: number; stagedAdditions: number; stagedDeletions: number; + // Per-repo handlers — wired only in multi-repo workspaces. Each one targets + // a single repo subpath in agentctl. Single-repo sessions don't render the + // per-repo group header, so these are unused in that case. + onRepoStageAll?: (repo: string) => void; + onRepoUnstageAll?: (repo: string) => void; + onRepoCommit?: (repo: string) => void; + onRepoPush?: (repo: string) => void; + onRepoCreatePR?: (repo: string) => void; + /** Maps a repository_name to its display label (used as the per-repo group label). */ + repoDisplayName?: (repositoryName: string) => string | undefined; + /** Per-repo branch / ahead / behind summary; powers the "ahead" badge on Push. */ + perRepoStatus?: Array<{ repository_name: string; ahead: number }>; + /** Existing PR URL keyed by repository_name; "" key for single-repo. */ + prByRepo?: Record; }; function ChangesPanelDialogsSection({ @@ -366,6 +209,14 @@ type TimelineProps = Pick< | "onBulkDiscard" | "onPush" | "onForcePush" + | "onRepoStageAll" + | "onRepoUnstageAll" + | "onRepoCommit" + | "onRepoPush" + | "onRepoCreatePR" + | "repoDisplayName" + | "perRepoStatus" + | "prByRepo" >; type WorkingTreeProps = Pick< @@ -386,6 +237,10 @@ type WorkingTreeProps = Pick< | "onBulkStage" | "onBulkUnstage" | "onBulkDiscard" + | "onRepoStageAll" + | "onRepoUnstageAll" + | "onRepoCommit" + | "repoDisplayName" > & { isLastUnstaged: boolean; isLastStaged: boolean }; function WorkingTreeSections(props: WorkingTreeProps) { @@ -408,6 +263,8 @@ function WorkingTreeSections(props: WorkingTreeProps) { onDiscard={props.dialogs.handleDiscardClick} onBulkStage={props.onBulkStage} onBulkDiscard={props.onBulkDiscard} + onRepoAction={props.onRepoStageAll} + repoDisplayName={props.repoDisplayName} /> )} {props.hasStaged && ( @@ -418,7 +275,7 @@ function WorkingTreeSections(props: WorkingTreeProps) { isLast={props.isLastStaged} actionLabel="Commit" isActionLoading={props.loadingOperation === "commit"} - onAction={props.dialogs.openCommitDialog} + onAction={() => props.dialogs.openCommitDialog()} secondaryActionLabel="Unstage all" isSecondaryActionLoading={isBulkOp && props.loadingOperation === "unstage"} onSecondaryAction={props.onUnstageAll} @@ -429,6 +286,9 @@ function WorkingTreeSections(props: WorkingTreeProps) { onDiscard={props.dialogs.handleDiscardClick} onBulkUnstage={props.onBulkUnstage} onBulkDiscard={props.onBulkDiscard} + onRepoAction={props.onRepoCommit} + onRepoSecondaryAction={props.onRepoUnstageAll} + repoDisplayName={props.repoDisplayName} /> )} @@ -460,6 +320,7 @@ function ChangesPanelTimeline(props: TimelineProps) { files={props.prFiles} isLast={!showCommitsList} onOpenDiff={props.onOpenDiffFile} + repoDisplayName={props.repoDisplayName} />
    )} @@ -477,6 +338,7 @@ function ChangesPanelTimeline(props: TimelineProps) { files={props.prFiles} isLast={!showCommitsList} onOpenDiff={props.onOpenDiffFile} + repoDisplayName={props.repoDisplayName} />
    )} @@ -489,19 +351,11 @@ function ChangesPanelTimeline(props: TimelineProps) { onRevertCommit={props.onRevertCommit} onAmendCommit={props.dialogs.handleOpenAmendDialog} onResetToCommit={props.dialogs.handleOpenResetDialog} - /> - )} - {showCommits && ( - )}
    @@ -524,119 +378,240 @@ function ChangesPanelBody(props: ChangesPanelBodyProps) { ); } +/** + * Adapts the existing single-repo handlers to the per-repo invocation sites + * (group-header buttons). Each callback simply forwards `repo` to the + * underlying op — the routing happens inside `useSessionGit` / + * `useGitOperations`. Single-repo passes the empty string, which the backend + * treats as the workspace root. + */ +function usePerRepoCallbacks( + git: ReturnType, + vcsDialogs: ReturnType, + gitHandlers: ReturnType, +) { + // Bug 9: route per-repo stage/unstage through `handleGitOperation` so toasts + // fire for both success and failure. The previous `.catch(() => undefined)` + // silenced rejections — the user got no feedback when an op failed. + return useMemo( + () => ({ + onRepoStageAll: (repo: string) => { + gitHandlers.handleGitOperation( + () => git.stage(undefined, repo), + repo ? `Stage all (${repo})` : "Stage all", + ); + }, + onRepoUnstageAll: (repo: string) => { + gitHandlers.handleGitOperation( + () => git.unstage(undefined, repo), + repo ? `Unstage all (${repo})` : "Unstage all", + ); + }, + onRepoCommit: (repo: string) => vcsDialogs.openCommitDialog(repo), + onRepoPush: (repo: string) => gitHandlers.handlePush(repo), + onRepoCreatePR: (repo: string) => vcsDialogs.openPRDialog(repo), + onRepoPull: (repo: string) => gitHandlers.handlePull(repo), + onRepoRebase: (repo: string) => gitHandlers.handleRebase(repo), + onRepoMerge: (repo: string) => gitHandlers.handleMerge(repo), + }), + [git, vcsDialogs, gitHandlers], + ); +} + function useChangesPanelPRData() { + // Multi-repo tasks have one TaskPR per repo; fan out file fetches per PR + // and stamp each file with its repository_name so PRFilesSection can + // group by repo. Commits stay scoped to the primary PR for now — the + // commits section already groups by repo from the workspace git status, + // not from the PR itself. + const { prs, filesByPRKey } = useActiveTaskPRsWithFiles(); + // Subscribe to the slice (stable ref between hydrations), then build the + // id -> name lookup with useMemo so the dependency in the next useMemo is + // stable and we don't re-create the merged file list on every render. + const reposByWorkspace = useAppStore((s) => s.repositories.itemsByWorkspaceId); + const repoNameById = useMemo>(() => { + const out: Record = {}; + for (const list of Object.values(reposByWorkspace)) { + for (const r of list as Array<{ id: string; name: string }>) { + out[r.id] = r.name; + } + } + return out; + }, [reposByWorkspace]); + // Tag PR files with their repo whenever the TASK is multi-repo, not just + // when there are 2+ PRs. A multi-repo task with one PR (e.g. one repo + // doesn't have a branch yet) still benefits from the per-repo header so + // the user can tell which repo's files are being shown — the COMMITS and + // STAGED sections already do this. + const taskHasMultipleRepos = useAppStore((s) => { + const taskId = s.tasks.activeTaskId; + if (!taskId) return false; + const task = s.kanban.tasks.find((t: { id: string }) => t.id === taskId); + return (task?.repositories?.length ?? 0) > 1; + }); const taskPR = useActiveTaskPR(); const refreshKey = taskPR?.last_synced_at ?? null; - const { files: prDiffFiles } = usePRDiff( - taskPR?.owner ?? null, - taskPR?.repo ?? null, - taskPR?.pr_number ?? null, - refreshKey, - ); const { commits: prCommitsList } = usePRCommits( taskPR?.owner ?? null, taskPR?.repo ?? null, taskPR?.pr_number ?? null, refreshKey, ); - const hasPRFiles = prDiffFiles.length > 0; + const { prFiles, prDiffFiles } = useMemo(() => { + const merged: PRChangedFile[] = []; + const flat: PRDiffFile[] = []; + for (const pr of prs) { + const key = `${pr.owner}/${pr.repo}/${pr.pr_number}/${pr.last_synced_at ?? ""}`; + const files = filesByPRKey[key] ?? []; + const repoName = pr.repository_id ? (repoNameById[pr.repository_id] ?? "") : ""; + // Single-repo tasks render flat (the section header is enough); + // multi-repo tasks always tag with the resolved repo name so the + // PRFilesGroupedList renders one sub-header per repo, even when only + // one repo has a PR yet. Falls back to owner/repo if the workspace + // lookup misses, so the row never lands in an untagged phantom group. + const stamp = taskHasMultipleRepos ? repoName || `${pr.owner}/${pr.repo}` : ""; + merged.push(...mapPRFilesToChangedFiles(files, stamp)); + flat.push(...files); + } + return { prFiles: merged, prDiffFiles: flat }; + }, [prs, filesByPRKey, repoNameById, taskHasMultipleRepos]); + const hasPRFiles = prFiles.length > 0; const hasPRCommits = prCommitsList.length > 0; - const prFiles = useMemo(() => mapPRFilesToChangedFiles(prDiffFiles), [prDiffFiles]); return { prDiffFiles, prCommitsList, hasPRFiles, hasPRCommits, prFiles }; } -const ChangesPanel = memo(function ChangesPanel({ - onOpenDiffFile, - onEditFile, - onOpenCommitDetail, - onOpenDiffAll, - onOpenReview, -}: ChangesPanelProps) { - const isArchived = useIsTaskArchived(); - const { activeSessionId, baseBranch, existingPrUrl } = useChangesPanelStoreData(); - +function useChangesPanelData() { + const { activeTaskId, activeSessionId, baseBranch, existingPrUrl } = useChangesPanelStoreData(); + const baseBranchByRepo = useBaseBranchByRepo(activeTaskId); const git = useSessionGit(activeSessionId); const { toast } = useToast(); const { reviews } = useSessionFileReviews(activeSessionId); - const { prDiffFiles, prCommitsList, hasPRFiles, hasPRCommits, prFiles } = useChangesPanelPRData(); + const prData = useChangesPanelPRData(); const vcsDialogs = useVcsDialogs(); - const baseBranchDisplay = useMemo(() => getBaseBranchDisplay(baseBranch), [baseBranch]); const unstagedFiles = useMemo(() => mapToChangedFiles(git.unstagedFiles), [git.unstagedFiles]); const stagedFiles = useMemo(() => mapToChangedFiles(git.stagedFiles), [git.stagedFiles]); - const { reviewedCount, totalFileCount } = useMemo( - () => computeReviewProgress(git.allFiles, git.cumulativeDiff, reviews, prDiffFiles), - [git.allFiles, git.cumulativeDiff, reviews, prDiffFiles], + () => computeReviewProgress(git.allFiles, git.cumulativeDiff, reviews, prData.prDiffFiles), + [git.allFiles, git.cumulativeDiff, reviews, prData.prDiffFiles], ); const staged = useMemo(() => computeStagedStats(git.stagedFiles), [git.stagedFiles]); - const gitHandlers = useChangesGitHandlers(git, toast, baseBranch); const localDialogs = useChangesDialogHandlers(git, toast, gitHandlers.handleGitOperation); const dialogs = { ...localDialogs, ...vcsDialogs }; + const repoCallbacks = usePerRepoCallbacks(git, vcsDialogs, gitHandlers); + const repoDisplayName = useRepoDisplayName(activeSessionId); + // Existing PR URL is currently workspace-scoped — surface it under the + // empty (single-repo) key so per-repo Create PR can show "PR exists". + const prByRepo = useMemo>( + () => ({ "": existingPrUrl }), + [existingPrUrl], + ); + return { + git, + baseBranchDisplay, + baseBranchByRepo, + unstagedFiles, + stagedFiles, + reviewedCount, + totalFileCount, + staged, + gitHandlers, + localDialogs, + dialogs, + repoCallbacks, + repoDisplayName, + prByRepo, + existingPrUrl, + ...prData, + }; +} +function buildChangesPanelBodyProps( + data: ReturnType, + callbacks: ChangesPanelProps, +): ChangesPanelBodyProps { + const { git, gitHandlers, localDialogs, repoCallbacks, staged } = data; + return { + hasAnything: git.hasAnything || data.hasPRFiles || data.hasPRCommits, + hasUnstaged: git.hasUnstaged, + hasStaged: git.hasStaged, + hasCommits: git.hasCommits, + hasPRFiles: data.hasPRFiles, + hasPRCommits: data.hasPRCommits, + canPush: git.canPush, + canCreatePR: git.canCreatePR, + existingPrUrl: data.existingPrUrl, + unstagedFiles: data.unstagedFiles, + stagedFiles: data.stagedFiles, + prFiles: data.prFiles, + prCommits: data.prCommitsList, + commits: git.commits, + pendingStageFiles: git.pendingStageFiles, + reviewedCount: data.reviewedCount, + totalFileCount: data.totalFileCount, + aheadCount: git.ahead, + isLoading: git.isLoading, + loadingOperation: git.loadingOperation, + dialogs: data.dialogs, + onOpenDiffFile: callbacks.onOpenDiffFile, + onEditFile: callbacks.onEditFile, + onOpenCommitDetail: callbacks.onOpenCommitDetail, + onRevertCommit: gitHandlers.handleRevertCommit, + onOpenReview: callbacks.onOpenReview, + onStageAll: git.stageAll, + onUnstageAll: git.unstageAll, + onStage: (path, repo) => git.stageFile([path], repo).then(() => undefined), + onUnstage: (path, repo) => git.unstageFile([path], repo).then(() => undefined), + onBulkStage: (paths) => { + git.stageFile(paths).catch(() => undefined); + }, + onBulkUnstage: (paths) => { + git.unstageFile(paths).catch(() => undefined); + }, + onBulkDiscard: localDialogs.handleBulkDiscardClick, + onPush: () => gitHandlers.handlePush(), + onForcePush: () => gitHandlers.handleForcePush(), + stagedFileCount: staged.stagedFileCount, + stagedAdditions: staged.stagedAdditions, + stagedDeletions: staged.stagedDeletions, + onRepoStageAll: repoCallbacks.onRepoStageAll, + onRepoUnstageAll: repoCallbacks.onRepoUnstageAll, + onRepoCommit: repoCallbacks.onRepoCommit, + onRepoPush: repoCallbacks.onRepoPush, + onRepoCreatePR: repoCallbacks.onRepoCreatePR, + repoDisplayName: data.repoDisplayName, + perRepoStatus: git.perRepoStatus, + prByRepo: data.prByRepo, + }; +} + +const ChangesPanel = memo(function ChangesPanel(props: ChangesPanelProps) { + const isArchived = useIsTaskArchived(); + const data = useChangesPanelData(); if (isArchived) return ; - return ( - git.stageFile([path]).then(() => undefined)} - onUnstage={(path) => git.unstageFile([path]).then(() => undefined)} - onBulkStage={(paths) => { - git.stageFile(paths).catch(() => {}); - }} - onBulkUnstage={(paths) => { - git.unstageFile(paths).catch(() => {}); - }} - onBulkDiscard={localDialogs.handleBulkDiscardClick} - onPush={gitHandlers.handlePush} - onForcePush={gitHandlers.handleForcePush} - stagedFileCount={staged.stagedFileCount} - stagedAdditions={staged.stagedAdditions} - stagedDeletions={staged.stagedDeletions} + hasChanges={data.git.hasChanges} + hasCommits={data.git.hasCommits} + hasPRFiles={data.hasPRFiles} + displayBranch={data.git.branch} + baseBranchDisplay={data.baseBranchDisplay} + baseBranchByRepo={data.baseBranchByRepo} + behindCount={data.git.behind} + isLoading={data.git.isLoading} + loadingOperation={data.git.loadingOperation} + onOpenDiffAll={props.onOpenDiffAll} + onOpenReview={props.onOpenReview} + repoNames={data.git.repoNames} + perRepoStatus={data.git.perRepoStatus} + onRepoPull={data.repoCallbacks.onRepoPull} + onRepoRebase={data.repoCallbacks.onRepoRebase} + onRepoMerge={data.repoCallbacks.onRepoMerge} + repoDisplayName={data.repoDisplayName} /> + ); }); diff --git a/apps/web/components/task/chat/chat-input-area.tsx b/apps/web/components/task/chat/chat-input-area.tsx index ceb35de04f..9e9b82153a 100644 --- a/apps/web/components/task/chat/chat-input-area.tsx +++ b/apps/web/components/task/chat/chat-input-area.tsx @@ -209,7 +209,7 @@ export function useChatPanelHandlers( } function PRMergedBanner({ taskId }: { taskId: string }) { - const taskPR = useAppStore((state) => state.taskPRs.byTaskId[taskId]); + const taskPRs = useAppStore((state) => state.taskPRs.byTaskId[taskId]); const [dismissed, setDismissed] = useState(false); const archiveAndSwitch = useArchiveAndSwitchTask(); const { toast } = useToast(); @@ -223,7 +223,15 @@ function PRMergedBanner({ taskId }: { taskId: string }) { } }, [taskId, archiveAndSwitch, toast]); - if (taskPR?.state !== "merged" || dismissed) return null; + // Multi-repo: only show "ready to archive" once every PR is merged. A + // single merged repo with others still open means the task isn't done yet. + const allMerged = !!taskPRs && taskPRs.length > 0 && taskPRs.every((pr) => pr.state === "merged"); + if (!allMerged || dismissed) return null; + + const bannerText = + taskPRs.length === 1 + ? `PR #${taskPRs[0].pr_number} has been merged. You can archive this task.` + : `All ${taskPRs.length} PRs have been merged. You can archive this task.`; return (
    - - PR #{taskPR.pr_number} has been merged. You can archive this task. - + {bannerText} + + + + + + Pick a repository for {primaryButtonConfig.label} + + ); +} diff --git a/apps/web/components/vcs-split-button.tsx b/apps/web/components/vcs-split-button.tsx index f3d52459c1..76169a9b32 100644 --- a/apps/web/components/vcs-split-button.tsx +++ b/apps/web/components/vcs-split-button.tsx @@ -29,6 +29,10 @@ import { useSessionGit } from "@/hooks/domains/session/use-session-git"; import { useGitWithFeedback } from "@/hooks/use-git-with-feedback"; import { useVcsDialogs } from "@/components/vcs/vcs-dialogs"; import { useActiveTaskPR } from "@/hooks/domains/github/use-task-pr"; +import { useRepoDisplayName } from "@/hooks/domains/session/use-repo-display-name"; +import { MultiRepoVcsButton } from "@/components/vcs-multi-repo-menu"; + +const DEFAULT_BASE_BRANCH = "origin/main"; function determinePrimaryAction( uncommittedFileCount: number, @@ -95,8 +99,8 @@ function buildRebaseConfig( return { icon: , label: "Rebase", - badge: null, - tooltip: `Rebase onto ${baseBranch || "origin/main"} (${behindCount} behind)`, + badge: behindCount > 0 ? behindCount : null, + tooltip: `Rebase onto ${baseBranch || DEFAULT_BASE_BRANCH} (${behindCount} behind)`, onClick: handleRebase, }; } @@ -247,12 +251,16 @@ function VcsDropdownItems({ Rebase - onto {baseBranch || "origin/main"} + + onto {baseBranch || DEFAULT_BASE_BRANCH} + Merge - from {baseBranch || "origin/main"} + + from {baseBranch || DEFAULT_BASE_BRANCH} + ); @@ -268,26 +276,40 @@ type VcsSplitButtonProps = { function useGitActions(git: ReturnType, baseBranch?: string) { const gitWithFeedback = useGitWithFeedback(); - const handlePull = useCallback(() => { - gitWithFeedback(() => git.pull(), "Pull"); - }, [gitWithFeedback, git]); - - const handlePush = useCallback( - (force = false) => { - gitWithFeedback(() => git.push({ force }), force ? "Force Push" : "Push"); + const handlePull = useCallback( + (repo?: string) => { + const label = repo ? `Pull (${repo})` : "Pull"; + gitWithFeedback(() => git.pull(false, repo), label); }, [gitWithFeedback, git], ); - const handleRebase = useCallback(() => { - const targetBranch = baseBranch?.replace(/^origin\//, "") || "main"; - gitWithFeedback(() => git.rebase(targetBranch), "Rebase"); - }, [gitWithFeedback, git, baseBranch]); + const handlePush = useCallback( + (force = false, repo?: string) => { + const baseLabel = force ? "Force Push" : "Push"; + const label = repo ? `${baseLabel} (${repo})` : baseLabel; + gitWithFeedback(() => git.push({ force }, repo), label); + }, + [gitWithFeedback, git], + ); - const handleMerge = useCallback(() => { - const targetBranch = baseBranch?.replace(/^origin\//, "") || "main"; - gitWithFeedback(() => git.merge(targetBranch), "Merge"); - }, [gitWithFeedback, git, baseBranch]); + const handleRebase = useCallback( + (repo?: string) => { + const targetBranch = baseBranch?.replace(/^origin\//, "") || "main"; + const label = repo ? `Rebase (${repo})` : "Rebase"; + gitWithFeedback(() => git.rebase(targetBranch, repo), label); + }, + [gitWithFeedback, git, baseBranch], + ); + + const handleMerge = useCallback( + (repo?: string) => { + const targetBranch = baseBranch?.replace(/^origin\//, "") || "main"; + const label = repo ? `Merge (${repo})` : "Merge"; + gitWithFeedback(() => git.merge(targetBranch, repo), label); + }, + [gitWithFeedback, git, baseBranch], + ); return { handlePull, handlePush, handleRebase, handleMerge }; } @@ -303,6 +325,7 @@ const VcsSplitButton = memo(function VcsSplitButton({ const activePR = useActiveTaskPR(); const hasOpenPR = activePR?.state === "open"; const { handlePull, handlePush, handleRebase, handleMerge } = useGitActions(git, baseBranch); + const repoDisplayName = useRepoDisplayName(sessionId); const currentBranch = git.branch; const remoteBranch = git.remoteBranch; @@ -313,6 +336,9 @@ const VcsSplitButton = memo(function VcsSplitButton({ const behindCount = git.behind; const isDisabled = git.isLoading || !sessionId; const isGitLoading = git.isLoading; + // Multi-repo when there's more than one named repo. Single-repo workspaces + // get either a single empty-name entry or no entries at all in repoNames. + const isMultiRepo = git.repoNames.filter((r) => r !== "").length > 1; const primaryAction = determinePrimaryAction( uncommittedFileCount, @@ -333,6 +359,86 @@ const VcsSplitButton = memo(function VcsSplitButton({ }); const showDivergencePills = primaryAction !== "commit"; + if (isMultiRepo) { + return ( + openCommitDialog(repo), + onPR: (repo) => openPRDialog(repo), + onPull: handlePull, + onPush: handlePush, + onRebase: handleRebase, + onMerge: handleMerge, + }} + /> + ); + } + + return ( + openPRDialog()} + onPull={() => handlePull()} + onPush={(force) => handlePush(force)} + onRebase={() => handleRebase()} + onMerge={() => handleMerge()} + /> + ); +}); + +function SingleRepoVcsButton({ + primaryButtonConfig, + primaryAction, + isDisabled, + isGitLoading, + baseBranch, + hasMatchingUpstream, + behindCount, + aheadCount, + onPR, + onPull, + onPush, + onRebase, + onMerge, + className, + buttonSize = "sm", + showDivergencePills = false, +}: { + primaryButtonConfig: PrimaryButtonConfig; + primaryAction: "commit" | "push" | "pr" | "rebase"; + isDisabled: boolean; + isGitLoading: boolean; + baseBranch?: string; + hasMatchingUpstream: boolean | "" | null | undefined; + behindCount: number; + aheadCount: number; + onPR: () => void; + onPull: () => void; + onPush: (force: boolean) => void; + onRebase: () => void; + onMerge: () => void; + className?: string; + buttonSize?: ComponentProps["size"]; + showDivergencePills?: boolean; +}) { return (
    @@ -379,15 +485,15 @@ const VcsSplitButton = memo(function VcsSplitButton({ hasMatchingUpstream={hasMatchingUpstream} behindCount={behindCount} aheadCount={aheadCount} - onPR={openPRDialog} - onPull={handlePull} - onPush={handlePush} - onRebase={handleRebase} - onMerge={handleMerge} + onPR={onPR} + onPull={onPull} + onPush={onPush} + onRebase={onRebase} + onMerge={onMerge} />
    ); -}); +} export { VcsSplitButton }; diff --git a/apps/web/components/vcs/vcs-dialogs.tsx b/apps/web/components/vcs/vcs-dialogs.tsx index 269ac271a5..df12ca729a 100644 --- a/apps/web/components/vcs/vcs-dialogs.tsx +++ b/apps/web/components/vcs/vcs-dialogs.tsx @@ -21,7 +21,12 @@ import { PRDescriptionField, PRBranchSummary, } from "./vcs-dialog-fields"; -import { useSessionGitStatus } from "@/hooks/domains/session/use-session-git-status"; +import { + useSessionGitStatus, + useSessionGitStatusByRepo, +} from "@/hooks/domains/session/use-session-git-status"; +import { useSessionGit } from "@/hooks/domains/session/use-session-git"; +import { useRepoDisplayName } from "@/hooks/domains/session/use-repo-display-name"; import { useGitOperations } from "@/hooks/use-git-operations"; import { useGitWithFeedback } from "@/hooks/use-git-with-feedback"; import { useUtilityAgentGenerator } from "@/hooks/use-utility-agent-generator"; @@ -30,8 +35,10 @@ import { useToast } from "@/components/toast-provider"; import type { FileInfo } from "@/lib/state/slices"; type VcsDialogsContextValue = { - openCommitDialog: () => void; - openPRDialog: () => void; + /** When `repo` is provided, the commit is scoped to that repo only. */ + openCommitDialog: (repo?: string) => void; + /** When `repo` is provided, the PR is scoped to that repo only. */ + openPRDialog: (repo?: string) => void; }; const VcsDialogsContext = createContext(null); @@ -52,17 +59,27 @@ type VcsDialogsProviderProps = { type FileSummary = { count: number; additions: number; deletions: number }; -function computeFileSummary(files: Record | undefined): FileSummary { - const count = files ? Object.keys(files).length : 0; +/** + * Counts files for the commit dialog summary. + * - When `stageAll=true` (the default), include every file (staged + unstaged) + * because the commit op stages them all before committing. + * - When `stageAll=false`, count only staged files — those are the only files + * the commit will actually include. Counting all here would over-state what + * the commit produces and surprise the user post-commit. + */ +function computeFileSummary( + files: Record | undefined, + stageAll: boolean = true, +): FileSummary { + if (!files) return { count: 0, additions: 0, deletions: 0 }; + const considered = (Object.values(files) as FileInfo[]).filter((f) => stageAll || f.staged); let additions = 0; let deletions = 0; - if (files && count > 0) { - for (const file of Object.values(files) as FileInfo[]) { - additions += file.additions || 0; - deletions += file.deletions || 0; - } + for (const file of considered) { + additions += file.additions || 0; + deletions += file.deletions || 0; } - return { count, additions, deletions }; + return { count: considered.length, additions, deletions }; } function FileSummaryText({ count, additions, deletions }: FileSummary) { @@ -85,6 +102,8 @@ function FileSummaryText({ count, additions, deletions }: FileSummary) { type CommitDialogProps = { open: boolean; onOpenChange: (v: boolean) => void; + /** When set, the dialog title shows the repo name and the summary is repo-scoped. */ + scopedRepo?: string; fileSummary: FileSummary; commitMessage: string; onCommitMessageChange: (v: string) => void; @@ -104,6 +123,7 @@ type CommitDialogProps = { function CommitDialog({ open, onOpenChange, + scopedRepo, fileSummary, commitMessage, onCommitMessageChange, @@ -125,7 +145,7 @@ function CommitDialog({ - Commit Changes + {scopedRepo ? `Commit Changes — ${scopedRepo}` : "Commit Changes"}
    @@ -198,6 +218,8 @@ function CommitDialog({ type PRDialogProps = { open: boolean; onOpenChange: (v: boolean) => void; + /** When set, the dialog title shows the repo name. */ + scopedRepo?: string; displayBranch?: string | null; baseBranch?: string; prTitle: string; @@ -218,6 +240,7 @@ type PRDialogProps = { function PRDialog({ open, onOpenChange, + scopedRepo, displayBranch, baseBranch, prTitle, @@ -240,7 +263,7 @@ function PRDialog({ - Create Pull Request + {scopedRepo ? `Create Pull Request — ${scopedRepo}` : "Create Pull Request"}
    @@ -304,7 +327,10 @@ type UseCommitDialogReturn = { setBody: (v: string) => void; stageAll: boolean; setStageAll: (v: boolean) => void; - openDialog: () => void; + /** Repo this commit is scoped to in multi-repo mode; "" = all repos with staged. */ + repo: string; + setRepo: (v: string) => void; + openDialog: (repo?: string) => void; }; function useCommitDialogState(): UseCommitDialogReturn { @@ -312,13 +338,29 @@ function useCommitDialogState(): UseCommitDialogReturn { const [message, setMessage] = useState(""); const [body, setBody] = useState(""); const [stageAll, setStageAll] = useState(true); - const openDialog = useCallback(() => { + const [repo, setRepo] = useState(""); + const openDialog = useCallback((nextRepo?: string) => { setMessage(""); setBody(""); setStageAll(true); + // Defensive: callers binding `openDialog` directly to onClick can leak the + // React MouseEvent into nextRepo. Only accept actual repo strings. + setRepo(typeof nextRepo === "string" ? nextRepo : ""); setOpen(true); }, []); - return { open, setOpen, message, setMessage, body, setBody, stageAll, setStageAll, openDialog }; + return { + open, + setOpen, + message, + setMessage, + body, + setBody, + stageAll, + setStageAll, + repo, + setRepo, + openDialog, + }; } type UsePRDialogReturn = { @@ -330,7 +372,10 @@ type UsePRDialogReturn = { setBody: (v: string) => void; draft: boolean; setDraft: (v: boolean) => void; - openDialog: (taskTitle?: string) => void; + /** Repo this PR is scoped to in multi-repo mode; "" = workspace root. */ + repo: string; + setRepo: (v: string) => void; + openDialog: (taskTitle?: string, repo?: string) => void; }; function usePRDialogState(): UsePRDialogReturn { @@ -338,12 +383,84 @@ function usePRDialogState(): UsePRDialogReturn { const [title, setTitle] = useState(""); const [body, setBody] = useState(""); const [draft, setDraft] = useState(true); - const openDialog = useCallback((taskTitle?: string) => { + const [repo, setRepo] = useState(""); + const openDialog = useCallback((taskTitle?: string, nextRepo?: string) => { setTitle(taskTitle || ""); setBody(""); + // Defensive: callers binding `openDialog` directly to onClick can leak the + // React MouseEvent into nextRepo. Only accept actual repo strings. + setRepo(typeof nextRepo === "string" ? nextRepo : ""); setOpen(true); }, []); - return { open, setOpen, title, setTitle, body, setBody, draft, setDraft, openDialog }; + return { + open, + setOpen, + title, + setTitle, + body, + setBody, + draft, + setDraft, + repo, + setRepo, + openDialog, + }; +} + +/** + * Computes the file summary for the commit dialog: an explicit repo scope + * uses that repo's files; an empty scope in multi-repo sums across every + * repo (showing the fan-out total); single-repo falls back to the legacy + * workspace-level status. + */ +function useScopedFileSummary({ + scopedRepo, + statusByRepo, + gitStatus, + isMultiRepo, + stageAll, +}: { + scopedRepo: string; + statusByRepo: ReturnType; + gitStatus: ReturnType; + isMultiRepo: boolean; + /** Mirrors the dialog's "Stage all changes before committing" checkbox. */ + stageAll: boolean; +}): FileSummary { + return useMemo(() => { + if (scopedRepo) { + const scoped = statusByRepo.find((s) => s.repository_name === scopedRepo); + return computeFileSummary(scoped?.status?.files, stageAll); + } + if (isMultiRepo) { + let count = 0; + let additions = 0; + let deletions = 0; + for (const { status } of statusByRepo) { + const s = computeFileSummary(status?.files, stageAll); + count += s.count; + additions += s.additions; + deletions += s.deletions; + } + return { count, additions, deletions }; + } + return computeFileSummary(gitStatus?.files, stageAll); + }, [scopedRepo, statusByRepo, gitStatus, isMultiRepo, stageAll]); +} + +/** + * Resolves the label shown in dialog titles. Explicit repo wins; otherwise + * empty scope resolves to the primary single-repo display name, or "All + * repos" when the workspace has multiple repos and the dialog is fanning out. + */ +function pickRepoLabel( + scopedRepo: string, + isMultiRepo: boolean, + resolveDisplayName: (name: string) => string | undefined, +): string { + if (scopedRepo) return resolveDisplayName(scopedRepo) || scopedRepo; + if (isMultiRepo) return "All repos"; + return resolveDisplayName("") || "Repository"; } function useCreatePRHandler( @@ -356,7 +473,13 @@ function useCreatePRHandler( if (!ps.title.trim()) return; ps.setOpen(false); try { - const result = await createPR(ps.title.trim(), ps.body.trim(), baseBranch, ps.draft); + const result = await createPR( + ps.title.trim(), + ps.body.trim(), + baseBranch, + ps.draft, + ps.repo || undefined, + ); if (result.success) { const title = ps.draft ? "Draft PR created" : "PR created"; toast({ @@ -384,6 +507,66 @@ function useCreatePRHandler( }, [ps, baseBranch, createPR, toast]); } +function useVcsDialogsState( + sessionId: string | null, + taskTitle: string | undefined, + baseBranch: string | undefined, +) { + const cs = useCommitDialogState(); + const ps = usePRDialogState(); + const { toast } = useToast(); + const gitWithFeedback = useGitWithFeedback(); + const gitStatus = useSessionGitStatus(sessionId); + const statusByRepo = useSessionGitStatusByRepo(sessionId); + // Use SessionGit so commit fans out per-repo for multi-repo workspaces. + // useGitOperations.commit hits the workspace root, which fails for multi-repo + // tasks because the task root isn't itself a git repo (exit 1). + const { commit, createPR, repoNames, isLoading: isGitLoading } = useSessionGit(sessionId); + const repoDisplayName = useRepoDisplayName(sessionId); + const isMultiRepo = repoNames.filter((r) => r !== "").length > 1; + const fileSummary = useScopedFileSummary({ + scopedRepo: cs.repo, + statusByRepo, + gitStatus, + isMultiRepo, + stageAll: cs.stageAll, + }); + const handleCommit = useCallback(async () => { + if (!cs.message.trim()) return; + cs.setOpen(false); + const title = cs.message.trim(); + const body = cs.body.trim(); + const fullMessage = body ? `${title}\n\n${body}` : title; + const label = cs.repo ? `Commit (${cs.repo})` : "Commit"; + await gitWithFeedback( + () => commit(fullMessage, cs.stageAll, false, cs.repo || undefined), + label, + ); + cs.setMessage(""); + cs.setBody(""); + cs.setRepo(""); + }, [cs, gitWithFeedback, commit]); + const handleCreatePR = useCreatePRHandler(ps, baseBranch, createPR, toast); + const contextValue = useMemo( + () => ({ + openCommitDialog: cs.openDialog, + openPRDialog: (repo?: string) => ps.openDialog(taskTitle, repo), + }), + [cs.openDialog, ps, taskTitle], + ); + return { + cs, + ps, + isGitLoading, + fileSummary, + handleCommit, + handleCreatePR, + contextValue, + repoDisplayName, + isMultiRepo, + }; +} + export function VcsDialogsProvider({ sessionId, baseBranch, @@ -391,12 +574,10 @@ export function VcsDialogsProvider({ displayBranch, children, }: VcsDialogsProviderProps) { - const cs = useCommitDialogState(); - const ps = usePRDialogState(); - const { toast } = useToast(); - const gitWithFeedback = useGitWithFeedback(); - const gitStatus = useSessionGitStatus(sessionId); - const { commit, createPR, isLoading: isGitLoading } = useGitOperations(sessionId); + const state = useVcsDialogsState(sessionId, taskTitle, baseBranch); + const { cs, ps, isGitLoading, fileSummary, handleCommit, handleCreatePR, contextValue } = state; + const effectiveRepoLabel = pickRepoLabel(cs.repo, state.isMultiRepo, state.repoDisplayName); + const effectivePRLabel = pickRepoLabel(ps.repo, state.isMultiRepo, state.repoDisplayName); const isUtilityConfigured = useIsUtilityConfigured(); const { isGeneratingCommitMessage, @@ -408,28 +589,6 @@ export function VcsDialogsProvider({ generatePRTitle, generatePRDescription, } = useUtilityAgentGenerator({ sessionId, taskTitle }); - const fileSummary = computeFileSummary(gitStatus?.files); - - const handleCommit = useCallback(async () => { - if (!cs.message.trim()) return; - cs.setOpen(false); - const title = cs.message.trim(); - const body = cs.body.trim(); - const fullMessage = body ? `${title}\n\n${body}` : title; - await gitWithFeedback(() => commit(fullMessage, cs.stageAll), "Commit"); - cs.setMessage(""); - cs.setBody(""); - }, [cs, gitWithFeedback, commit]); - - const handleCreatePR = useCreatePRHandler(ps, baseBranch, createPR, toast); - - const contextValue = useMemo( - () => ({ - openCommitDialog: cs.openDialog, - openPRDialog: () => ps.openDialog(taskTitle), - }), - [cs.openDialog, ps, taskTitle], - ); return ( @@ -437,6 +596,7 @@ export function VcsDialogsProvider({ { // Verify both commits appear const commitsList = testPage.getByTestId("commits-list"); - await expect(commitsList.locator("li")).toHaveCount(2, { timeout: 5_000 }); + await expect(commitsList.locator('[data-testid^="commit-row-"]')).toHaveCount(2, { + timeout: 5_000, + }); // Find the first commit row (it's the second in the list, older commit) // The list shows newest first, so "Second commit" is at index 0, "First commit" at index 1 - const firstCommitRow = commitsList.locator("li").filter({ hasText: "First commit" }); + const firstCommitRow = commitsList + .locator('[data-testid^="commit-row-"]') + .filter({ hasText: "First commit" }); await expect(firstCommitRow).toBeVisible({ timeout: 5_000 }); // Get the SHA from the commit row to use in the confirmation @@ -1275,7 +1279,9 @@ test.describe("Git Changes Panel", () => { await expect(testPage.getByTestId("commits-section")).toBeVisible({ timeout: 15_000 }); await expect(session.changes.getByText("Commit in PR")).toBeVisible({ timeout: 10_000 }); const commitsList = testPage.getByTestId("commits-list"); - await expect(commitsList.locator("li")).toHaveCount(1, { timeout: 5_000 }); + await expect(commitsList.locator('[data-testid^="commit-row-"]')).toHaveCount(1, { + timeout: 5_000, + }); // Pushed commits use IconGitCommit (tabler-icon-git-commit), not IconArrowUp await expect(commitsList.locator(".tabler-icon-git-commit")).toBeVisible({ timeout: 5_000 }); await expect(commitsList.locator(".tabler-icon-arrow-up")).not.toBeVisible(); @@ -1359,16 +1365,23 @@ test.describe("Git Changes Panel", () => { // Other tests in the same worker may have left additional commits in the shared e2e-repo. await expect(testPage.getByTestId("commits-section")).toBeVisible({ timeout: 15_000 }); const commitsList = testPage.getByTestId("commits-list"); - await expect(commitsList.locator("li").first()).toBeVisible({ timeout: 5_000 }); + await expect(commitsList.locator('[data-testid^="commit-row-"]').first()).toBeVisible({ + timeout: 5_000, + }); - // Unpushed commit should have arrow-up icon (emerald) - const unpushedRow = commitsList.locator("li", { hasText: "Unpushed commit" }); + // Unpushed commit should have arrow-up icon (emerald). Scope to the + // commit-row testid so we don't match the parent repo-group
  • too + // (which also contains the same text since it wraps the commits). + const unpushedRow = commitsList + .locator('[data-testid^="commit-row-"]', { hasText: "Unpushed commit" }) + .first(); await expect(unpushedRow).toBeVisible({ timeout: 10_000 }); await expect(unpushedRow.locator(".tabler-icon-arrow-up")).toBeVisible({ timeout: 5_000 }); // Pushed commit should have git-commit icon (muted) const pushedRow = commitsList - .locator(`li:has-text("Pushed commit"):not(:has-text("Unpushed"))`) + .locator('[data-testid^="commit-row-"]', { hasText: "Pushed commit" }) + .filter({ hasNotText: "Unpushed" }) .first(); await expect(pushedRow).toBeVisible({ timeout: 10_000 }); await expect(pushedRow.locator(".tabler-icon-git-commit")).toBeVisible({ timeout: 5_000 }); diff --git a/apps/web/e2e/tests/task/create-task-branch-selector.spec.ts b/apps/web/e2e/tests/task/create-task-branch-selector.spec.ts index 97a1650de0..da72262b1c 100644 --- a/apps/web/e2e/tests/task/create-task-branch-selector.spec.ts +++ b/apps/web/e2e/tests/task/create-task-branch-selector.spec.ts @@ -43,7 +43,7 @@ test.describe("Branch selector behavior with executor types", () => { await testPage.getByRole("option", { name: /E2E Local Profile/i }).click(); // Branch selector should be disabled when local executor is selected - const branchSelector = testPage.getByTestId("branch-selector"); + const branchSelector = testPage.getByTestId("branch-chip-trigger").first(); await expect(branchSelector).toBeDisabled({ timeout: 5_000 }); } finally { await apiClient.deleteExecutorProfile(profile.id).catch(() => {}); @@ -85,7 +85,7 @@ test.describe("Branch selector behavior with executor types", () => { await executorSelector.click(); await testPage.getByRole("option", { name: /^E2E Local Local$/i }).click(); - const branchSelector = testPage.getByTestId("branch-selector"); + const branchSelector = testPage.getByTestId("branch-chip-trigger").first(); await expect(branchSelector).toBeDisabled({ timeout: 5_000 }); // Switch to worktree executor -> branch should be enabled @@ -162,7 +162,7 @@ test.describe("Branch selector behavior with executor types", () => { await testPage.getByRole("option", { name: /E2E Local GitHub URL/i }).click(); // Branch selector should NOT be disabled (GitHub URL mode overrides) - const branchSelector = testPage.getByTestId("branch-selector"); + const branchSelector = testPage.getByTestId("branch-chip-trigger").first(); await expect(branchSelector).toBeEnabled({ timeout: 10_000 }); } finally { await apiClient.deleteExecutorProfile(profile.id).catch(() => {}); @@ -228,7 +228,7 @@ test.describe("Fresh-branch flow", () => { await expect(testPage.getByTestId("create-task-dialog")).toBeVisible(); await testPage.getByTestId("task-title-input").fill("Fresh Branch Test"); await testPage.getByTestId("task-description-input").fill("testing fresh branch"); - await testPage.getByTestId("repository-selector").click(); + await testPage.getByTestId("repo-chip-trigger").first().click(); await testPage .getByRole("option", { name: new RegExp(`^${escapeRe(repoName)}\\b`, "i") }) .first() @@ -256,7 +256,7 @@ test.describe("Fresh-branch flow", () => { const toggle = testPage.getByTestId("fresh-branch-toggle"); await expect(toggle).toBeVisible(); await expect(toggle).toHaveAttribute("aria-pressed", "false"); - const branchSelector = testPage.getByTestId("branch-selector"); + const branchSelector = testPage.getByTestId("branch-chip-trigger").first(); await expect(branchSelector).toBeDisabled({ timeout: 5_000 }); // Placeholder should show actual current branch (main), not the generic copy. await expect(branchSelector).toContainText("main", { timeout: 5_000 }); @@ -279,7 +279,7 @@ test.describe("Fresh-branch flow", () => { try { await openDialogWithLocalProfile(testPage, setup.profileName, setup.repoName); await testPage.getByTestId("fresh-branch-toggle").click(); - const branchSelector = testPage.getByTestId("branch-selector"); + const branchSelector = testPage.getByTestId("branch-chip-trigger").first(); await expect(branchSelector).toBeEnabled({ timeout: 5_000 }); // Pick the develop base branch so the new branch will fork from it. await branchSelector.click(); @@ -481,7 +481,7 @@ test.describe("Fresh-branch flow", () => { await expect(testPage.getByTestId("create-task-dialog")).toBeVisible(); await testPage.getByTestId("task-title-input").fill("Switcheroo"); await testPage.getByTestId("task-description-input").fill("repro the leftover-branch bug"); - await testPage.getByTestId("repository-selector").click(); + await testPage.getByTestId("repo-chip-trigger").first().click(); await testPage .getByRole("option", { name: new RegExp(`^${setup.repoName}\\b`, "i") }) .first() @@ -495,7 +495,7 @@ test.describe("Fresh-branch flow", () => { .filter({ hasText: /worktree/i }) .first() .click(); - const branchSelector = testPage.getByTestId("branch-selector"); + const branchSelector = testPage.getByTestId("branch-chip-trigger").first(); await expect(branchSelector).toBeEnabled({ timeout: 5_000 }); await branchSelector.click(); await testPage @@ -563,7 +563,7 @@ test.describe("Branch refresh + filter", () => { await expect(testPage.getByTestId("create-task-dialog")).toBeVisible(); await testPage.getByTestId("task-title-input").fill("Refresh button test"); await testPage.getByTestId("task-description-input").fill("triggers git fetch"); - await testPage.getByTestId("repository-selector").click(); + await testPage.getByTestId("repo-chip-trigger").first().click(); await testPage .getByRole("option", { name: new RegExp(`^${escapeRe(seededRepoName)}\\b`, "i") }) .first() @@ -572,7 +572,7 @@ test.describe("Branch refresh + filter", () => { await testPage.getByTestId("executor-profile-selector").click(); await testPage.getByRole("option", { name: worktreeProfileName }).click(); - const branchSelector = testPage.getByTestId("branch-selector"); + const branchSelector = testPage.getByTestId("branch-chip-trigger").first(); await expect(branchSelector).toBeEnabled({ timeout: 5_000 }); await branchSelector.click(); @@ -634,7 +634,7 @@ test.describe("Branch refresh + filter", () => { await expect(testPage.getByTestId("create-task-dialog")).toBeVisible(); await testPage.getByTestId("task-title-input").fill("Filter ranking test"); await testPage.getByTestId("task-description-input").fill("exact match wins"); - await testPage.getByTestId("repository-selector").click(); + await testPage.getByTestId("repo-chip-trigger").first().click(); await testPage .getByRole("option", { name: new RegExp(`^${repoName}\\b`, "i") }) .first() @@ -642,7 +642,7 @@ test.describe("Branch refresh + filter", () => { await testPage.getByTestId("executor-profile-selector").click(); await testPage.getByRole("option", { name: worktreeProfileName }).click(); - const branchSelector = testPage.getByTestId("branch-selector"); + const branchSelector = testPage.getByTestId("branch-chip-trigger").first(); await expect(branchSelector).toBeEnabled({ timeout: 5_000 }); await branchSelector.click(); diff --git a/apps/web/e2e/tests/task/create-task-github-url.spec.ts b/apps/web/e2e/tests/task/create-task-github-url.spec.ts index aadd2dda9f..0fdf2c7dc1 100644 --- a/apps/web/e2e/tests/task/create-task-github-url.spec.ts +++ b/apps/web/e2e/tests/task/create-task-github-url.spec.ts @@ -904,7 +904,7 @@ test.describe("Task creation from 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"); + await expect(toggleBtn).toHaveText("use a workspace repository"); // Toggle back to repository selector await toggleBtn.click(); diff --git a/apps/web/e2e/tests/task/create-task.spec.ts b/apps/web/e2e/tests/task/create-task.spec.ts index 134500c479..4e651e2c17 100644 --- a/apps/web/e2e/tests/task/create-task.spec.ts +++ b/apps/web/e2e/tests/task/create-task.spec.ts @@ -25,9 +25,10 @@ test.describe("Task creation", () => { const startBtn = testPage.getByTestId(START_AGENT_TEST_ID); await expect(startBtn).toBeEnabled({ timeout: START_ENABLED_TIMEOUT }); - // Verify the pre-seeded selections are displayed in the dialog selectors - await expect(testPage.getByTestId("repository-selector")).toContainText("E2E Repo"); - await expect(testPage.getByTestId("branch-selector")).toContainText("main"); + // Verify the pre-seeded selections are displayed in the dialog selectors. + // The dialog uses chip-style repo/branch pills since the multi-repo refactor. + await expect(testPage.getByTestId("repo-chip-trigger").first()).toContainText("E2E Repo"); + await expect(testPage.getByTestId("branch-chip-trigger").first()).toContainText("main"); // Agent profile name varies — just verify it's not the empty placeholder await expect(testPage.getByTestId("agent-profile-selector")).not.toContainText( "Select agent...", @@ -62,8 +63,8 @@ test.describe("Task creation", () => { await expect(startBtn2).toBeEnabled({ timeout: START_ENABLED_TIMEOUT }); // The same repo, branch, and agent profile should still be selected - await expect(testPage.getByTestId("repository-selector")).toContainText("E2E Repo"); - await expect(testPage.getByTestId("branch-selector")).toContainText("main"); + await expect(testPage.getByTestId("repo-chip-trigger").first()).toContainText("E2E Repo"); + await expect(testPage.getByTestId("branch-chip-trigger").first()).toContainText("main"); await expect(testPage.getByTestId("agent-profile-selector")).not.toContainText( "Select agent...", ); diff --git a/apps/web/e2e/tests/task/repository-selector-scroll.spec.ts b/apps/web/e2e/tests/task/repository-selector-scroll.spec.ts index 9046da59e6..0edce8beb5 100644 --- a/apps/web/e2e/tests/task/repository-selector-scroll.spec.ts +++ b/apps/web/e2e/tests/task/repository-selector-scroll.spec.ts @@ -44,8 +44,10 @@ test.describe("repository selector scroll inside dialog", () => { const dialog = testPage.getByTestId("create-task-dialog"); await expect(dialog).toBeVisible(); - // Open the repository selector popover. - await testPage.getByTestId("repository-selector").click(); + // Open the repository selector popover via the chip-row trigger + // (the dialog now uses the multi-repo chip layout, not the legacy + // single repository-selector combobox). + await testPage.getByTestId("repo-chip-trigger").first().click(); // The popover's cmdk list must live inside the dialog DOM (not portaled // to body), otherwise react-remove-scroll eats wheel events. diff --git a/apps/web/hooks/domains/github/use-active-task-pr-files.ts b/apps/web/hooks/domains/github/use-active-task-pr-files.ts new file mode 100644 index 0000000000..0d66d89fca --- /dev/null +++ b/apps/web/hooks/domains/github/use-active-task-pr-files.ts @@ -0,0 +1,124 @@ +"use client"; + +import { useEffect, useMemo, useRef, useState } from "react"; +import { useAppStore } from "@/components/state-provider"; +import { getWebSocketClient } from "@/lib/ws/connection"; +import type { PRDiffFile, TaskPR } from "@/lib/types/github"; + +type PRFilesByKey = Record; + +// Stable empty array so the Zustand selector returns the same reference +// for tasks with zero PRs. A fresh `[]` per render would re-trigger the +// selector subscriber and cascade through every effect that depends on +// `prs`. +const EMPTY_PRS: TaskPR[] = []; + +/** + * Cache key for an in-flight fetch — owner/repo/PR + the last_synced_at hint + * from the TaskPR row, so a server-side sync invalidates the cache and + * triggers a refetch automatically. + */ +function fetchKey(pr: TaskPR): string { + return `${pr.owner}/${pr.repo}/${pr.pr_number}/${pr.last_synced_at ?? ""}`; +} + +/** + * Returns one diff array per task PR, keyed by `${owner}/${repo}/${prNumber}/${last_synced_at}`. + * Internally fans out one WS request per PR and tracks them in local state — + * we can't use `usePRDiff` directly because hooks can't be called in a loop. + * + * Designed for the changes panel's PR Changes section, which needs to render + * one row per file across every per-repo PR (multi-repo tasks now have one + * PR per repo, not just one for the whole task). + */ +export function useActiveTaskPRsWithFiles(): { + prs: TaskPR[]; + filesByPRKey: PRFilesByKey; +} { + const prs = useAppStore((s) => { + const taskId = s.tasks.activeTaskId; + if (!taskId) return EMPTY_PRS; + return s.taskPRs.byTaskId[taskId] ?? EMPTY_PRS; + }); + + const [filesByPRKey, setFilesByPRKey] = useState({}); + // Refs so we can synchronously skip duplicate fetches without extra + // state updates (the lint rule rightly objects to setState-in-effect). + // Reset whenever the desired key set changes — a new last_synced_at + // counts as a brand-new fetch. + const inFlightRef = useRef>(new Set()); + const fetchedRef = useRef>(new Set()); + + // The set of keys we *want* to have results for. Drives the diff between + // current state and what needs fetching, and lets us GC stale entries + // (e.g. when a PR is deleted upstream or last_synced_at advances). + const desiredKeys = useMemo(() => prs.map(fetchKey), [prs]); + + // Drop cached results / tracking refs whose key is no longer desired. + // Without this, switching tasks would leak stale PR file lists forever. + // The setState is the GC step for an external (Zustand) state change — + // pruneByKeySet returns the same reference when nothing changed, so this + // does not cause cascading renders. + useEffect(() => { + const desiredSet = new Set(desiredKeys); + for (const k of inFlightRef.current) { + if (!desiredSet.has(k)) inFlightRef.current.delete(k); + } + for (const k of fetchedRef.current) { + if (!desiredSet.has(k)) fetchedRef.current.delete(k); + } + // eslint-disable-next-line react-hooks/set-state-in-effect -- GC for external store change; no-op when nothing was pruned. + setFilesByPRKey((prev) => pruneByKeySet(prev, desiredSet)); + }, [desiredKeys]); + + // Issue one fetch per PR that hasn't been fetched yet under its current key. + useEffect(() => { + const client = getWebSocketClient(); + if (!client) return; + for (const pr of prs) { + const key = fetchKey(pr); + if (fetchedRef.current.has(key) || inFlightRef.current.has(key)) continue; + inFlightRef.current.add(key); + void client + .request<{ files?: PRDiffFile[] }>("github.pr_files.get", { + owner: pr.owner, + repo: pr.repo, + number: pr.pr_number, + }) + .then((response) => { + inFlightRef.current.delete(key); + fetchedRef.current.add(key); + setFilesByPRKey((prev) => ({ ...prev, [key]: response?.files ?? [] })); + }) + .catch(() => { + inFlightRef.current.delete(key); + fetchedRef.current.add(key); + setFilesByPRKey((prev) => ({ ...prev, [key]: [] })); + }); + } + // No cleanup-time cancellation: the per-key dedup via inFlightRef + + // fetchedRef already prevents duplicate requests, and the response + // handlers use functional setState so they're safe to land after the + // effect re-runs. Adding `cancelled = true` here used to drop responses + // from the previous effect instance — and since the next effect's + // early-continue saw the key still in inFlightRef, no fresh request + // was issued either, leaving files permanently empty. + }, [prs]); + + return { prs, filesByPRKey }; +} + +function pruneByKeySet(prev: Record, desiredSet: Set): Record { + let changed = false; + const next: Record = {}; + for (const k of Object.keys(prev)) { + if (desiredSet.has(k)) { + next[k] = prev[k]; + } else { + changed = true; + } + } + return changed ? next : prev; +} + +export { fetchKey as prFetchKey }; diff --git a/apps/web/hooks/domains/github/use-task-pr.ts b/apps/web/hooks/domains/github/use-task-pr.ts index 8971e1303c..e006d4a55a 100644 --- a/apps/web/hooks/domains/github/use-task-pr.ts +++ b/apps/web/hooks/domains/github/use-task-pr.ts @@ -45,9 +45,33 @@ export function useWorkspacePRs(workspaceId: string | null) { const SYNC_RETRY_DELAY = 5_000; // 5 seconds const SYNC_MAX_RETRIES = 6; // Up to 30 seconds of retries -/** Fetch a single task's PR association, with on-demand sync via WS. */ +/** + * Returns the primary PR (first by created_at) for a task. Multi-repo tasks + * may have additional PRs — use `useTaskPRs` to get the full list. + */ +export function getPrimaryTaskPR(prs: TaskPR[] | undefined): TaskPR | null { + return prs && prs.length > 0 ? prs[0] : null; +} + +/** + * Normalises the WS sync response into an array of TaskPR rows. Backend + * returns `{prs: TaskPR[]}` (current shape) for multi-repo support, but we + * accept the legacy bare-TaskPR shape too in case an older backend is + * still running. Empty / null / unknown shapes return an empty array. + */ +function normalizeSyncResponse(result: { prs?: TaskPR[] } | TaskPR | null | undefined): TaskPR[] { + if (!result) return []; + const envelope = result as { prs?: TaskPR[] }; + if (Array.isArray(envelope.prs)) return envelope.prs; + const single = result as TaskPR; + if (single.task_id) return [single]; + return []; +} + +/** Fetch a single task's PR associations, with on-demand sync via WS. */ export function useTaskPR(taskId: string | null) { - const pr = useAppStore((state) => (taskId ? (state.taskPRs.byTaskId[taskId] ?? null) : null)); + const prs = useAppStore((state) => (taskId ? (state.taskPRs.byTaskId[taskId] ?? null) : null)); + const pr = getPrimaryTaskPR(prs ?? undefined); const setTaskPR = useAppStore((state) => state.setTaskPR); const retryRef = useRef(0); @@ -56,13 +80,20 @@ export function useTaskPR(taskId: string | null) { const client = getWebSocketClient(); if (!client) return; + // Backend returns `{prs: TaskPR[]}` — multi-repo tasks have one row per + // repo. We push each into the store so the per-repo PR icon stays in + // sync. Empty array means no watches yet (the freshness retry below + // handles the polling cadence). Legacy single-PR shape (`TaskPR` only) + // is detected via the absence of `.prs`. client - .request("github.task_pr.sync", { task_id: taskId }) + .request<{ prs?: TaskPR[] } | TaskPR | null>("github.task_pr.sync", { task_id: taskId }) .then((result) => { - if (result?.task_id) { - setTaskPR(taskId, result); - retryRef.current = 0; + const list = normalizeSyncResponse(result); + if (list.length === 0) return; + for (const pr of list) { + if (pr.task_id) setTaskPR(taskId, pr); } + retryRef.current = 0; }) .catch(() => { // Ignore - sync may fail if no watch exists @@ -97,13 +128,18 @@ export function useTaskPR(taskId: string | null) { return () => clearInterval(interval); }, [taskId, pr, refresh]); - return { pr, refresh } as { pr: TaskPR | null; refresh: () => void }; + return { pr, prs: prs ?? [], refresh } as { + pr: TaskPR | null; + prs: TaskPR[]; + refresh: () => void; + }; } -/** Read the active task's PR from the store (no fetching). */ +/** Read the active task's primary PR from the store (no fetching). */ export function useActiveTaskPR(): TaskPR | null { return useAppStore((s) => { const taskId = s.tasks.activeTaskId; - return taskId ? (s.taskPRs.byTaskId[taskId] ?? null) : null; + if (!taskId) return null; + return getPrimaryTaskPR(s.taskPRs.byTaskId[taskId]); }); } diff --git a/apps/web/hooks/domains/kanban/index.ts b/apps/web/hooks/domains/kanban/index.ts index 520f6c401c..f9273c579f 100644 --- a/apps/web/hooks/domains/kanban/index.ts +++ b/apps/web/hooks/domains/kanban/index.ts @@ -1,3 +1,4 @@ export { useKanbanData } from "./use-kanban-data"; export { useKanbanActions } from "./use-kanban-actions"; export { useKanbanNavigation } from "./use-kanban-navigation"; +export { useTaskRepositories, useTaskPrimaryRepository } from "./use-task-repositories"; diff --git a/apps/web/hooks/domains/kanban/use-kanban-data.ts b/apps/web/hooks/domains/kanban/use-kanban-data.ts index 8b37eee385..3922b8c023 100644 --- a/apps/web/hooks/domains/kanban/use-kanban-data.ts +++ b/apps/web/hooks/domains/kanban/use-kanban-data.ts @@ -71,6 +71,7 @@ export function useKanbanData({ description: task.description, position: task.position, repositoryId: task.repositoryId, + repositories: task.repositories, primarySessionId: task.primarySessionId, sessionCount: task.sessionCount, reviewStatus: task.reviewStatus, diff --git a/apps/web/hooks/domains/kanban/use-task-repositories.ts b/apps/web/hooks/domains/kanban/use-task-repositories.ts new file mode 100644 index 0000000000..b86ef74a86 --- /dev/null +++ b/apps/web/hooks/domains/kanban/use-task-repositories.ts @@ -0,0 +1,41 @@ +"use client"; + +import { useMemo } from "react"; +import { useAppStore } from "@/components/state-provider"; +import type { KanbanState } from "@/lib/state/slices"; + +/** + * Slim per-task repository shape carried in the kanban store. Mirrors + * KanbanState["tasks"][number]["repositories"][number]. + */ +export type KanbanTaskRepository = NonNullable< + KanbanState["tasks"][number]["repositories"] +>[number]; + +/** + * Returns the repositories linked to a task, ordered by Position. Empty + * array for repo-less tasks. The hook reads from the kanban tasks slice; + * use this instead of poking task.repositories directly so multi-repo + * consumers all share one source of truth. + */ +export function useTaskRepositories(taskId: string | null | undefined): KanbanTaskRepository[] { + const task = useAppStore((state) => + taskId + ? (state.kanban.tasks.find((t: KanbanState["tasks"][number]) => t.id === taskId) ?? null) + : null, + ); + return useMemo(() => { + const repos = task?.repositories ?? []; + return [...repos].sort((a, b) => a.position - b.position); + }, [task]); +} + +/** + * Returns the primary repository for a task (lowest position), or null. + */ +export function useTaskPrimaryRepository( + taskId: string | null | undefined, +): KanbanTaskRepository | null { + const repos = useTaskRepositories(taskId); + return repos.length > 0 ? repos[0] : null; +} diff --git a/apps/web/hooks/domains/session/use-base-branch-by-repo.ts b/apps/web/hooks/domains/session/use-base-branch-by-repo.ts new file mode 100644 index 0000000000..bdec1a0e5d --- /dev/null +++ b/apps/web/hooks/domains/session/use-base-branch-by-repo.ts @@ -0,0 +1,32 @@ +"use client"; + +import { useMemo } from "react"; +import { useAppStore } from "@/components/state-provider"; +import type { Repository } from "@/lib/types/http"; + +/** + * Returns a map from `repository_name` to its task base_branch for the active + * task. Multi-repo tasks store one base_branch per repo (e.g. front: `main`, + * back: `release/24.x`); UI surfaces that show the merge target need this + * resolution to avoid pretending one workspace-level branch covers every repo. + * + * Empty for single-repo tasks (callers fall back to the workspace-level + * baseBranchDisplay) and for tasks not yet hydrated. + */ +export function useBaseBranchByRepo(activeTaskId: string | null): Record { + const tasks = useAppStore((s) => s.kanban.tasks); + const reposByWorkspace = useAppStore((s) => s.repositories.itemsByWorkspaceId); + return useMemo(() => { + if (!activeTaskId) return {}; + const task = tasks.find((t) => t.id === activeTaskId); + if (!task?.repositories?.length) return {}; + const allRepos = Object.values(reposByWorkspace).flat() as Repository[]; + const repoNameById = new Map(allRepos.map((r) => [r.id, r.name])); + const out: Record = {}; + for (const link of task.repositories) { + const name = repoNameById.get(link.repository_id); + if (name && link.base_branch) out[name] = link.base_branch; + } + return out; + }, [activeTaskId, tasks, reposByWorkspace]); +} diff --git a/apps/web/hooks/domains/session/use-repo-display-name.ts b/apps/web/hooks/domains/session/use-repo-display-name.ts new file mode 100644 index 0000000000..f6bc1ce91c --- /dev/null +++ b/apps/web/hooks/domains/session/use-repo-display-name.ts @@ -0,0 +1,69 @@ +"use client"; + +import { useMemo } from "react"; +import { useAppStore } from "@/components/state-provider"; + +/** + * Resolves the workspace's primary single-repo name from the active task and + * repositories slice. Returns undefined when: + * - the task hasn't loaded yet (Bug 5 loading-order concern: `tasks` and + * `reposByWorkspace` hydrate independently via SSR + WS, so the task can be + * missing on first render) + * - the task has multiple repos (the empty-name fallback would mislabel any + * untagged row as the primary repo) + * - no matching repo entry was found in any workspace + * + * Extracted so the resolver returned by `useRepoDisplayName` can be a stable + * closure over a single primitive and React Compiler can preserve memoization. + */ +type TaskLike = { + id: string; + repositoryId?: string | null; + repositories?: unknown[]; +}; +type RepoEntry = { id: string; name: string }; + +function resolvePrimaryRepoName( + taskId: string | null, + tasks: TaskLike[], + reposByWorkspace: Record, +): string | undefined { + const task = taskId ? tasks.find((t) => t.id === taskId) : undefined; + if (task === undefined) return undefined; + const primaryRepoId = task.repositoryId ?? null; + const taskHasMultipleRepos = (task.repositories?.length ?? 0) > 1; + if (taskHasMultipleRepos || !primaryRepoId) return undefined; + for (const list of Object.values(reposByWorkspace)) { + const found = list.find((r) => r.id === primaryRepoId); + if (found) return found.name; + } + return undefined; +} + +/** + * Resolves a repository_name (as reported by agentctl in git status) to a + * human-readable label for the UI. Non-empty inputs pass through unchanged; + * empty inputs fall back to the workspace's primary repo name when safely + * resolvable, otherwise undefined (callers render a neutral "Repository"). + */ +export function useRepoDisplayName(sessionId: string | null | undefined) { + const session = useAppStore((state) => (sessionId ? state.taskSessions.items[sessionId] : null)); + const taskId = session?.task_id ?? null; + const tasks = useAppStore((state) => state.kanban.tasks); + const reposByWorkspace = useAppStore((state) => state.repositories.itemsByWorkspaceId); + // Resolve to a single primitive (or undefined) so the returned closure is + // memoized cleanly without React Compiler bailing on a branched return. + const primaryName = useMemo( + () => + resolvePrimaryRepoName( + taskId, + tasks as unknown as TaskLike[], + reposByWorkspace as unknown as Record, + ), + [taskId, tasks, reposByWorkspace], + ); + return useMemo( + () => (repositoryName: string) => repositoryName || primaryName || undefined, + [primaryName], + ); +} diff --git a/apps/web/hooks/domains/session/use-session-git-grouping.test.ts b/apps/web/hooks/domains/session/use-session-git-grouping.test.ts new file mode 100644 index 0000000000..e0c3851b8b --- /dev/null +++ b/apps/web/hooks/domains/session/use-session-git-grouping.test.ts @@ -0,0 +1,31 @@ +import { describe, it, expect } from "vitest"; +import { groupPathsByRepoName } from "./use-session-git"; + +describe("groupPathsByRepoName", () => { + it("splits paths by repository_name, preserving insertion order per bucket", () => { + const lookup = new Map([ + ["src/app.tsx", "frontend"], + ["src/api.ts", "frontend"], + ["handlers/task.go", "backend"], + ]); + const out = groupPathsByRepoName(["src/app.tsx", "handlers/task.go", "src/api.ts"], lookup); + expect(out.get("frontend")).toEqual(["src/app.tsx", "src/api.ts"]); + expect(out.get("backend")).toEqual(["handlers/task.go"]); + expect(out.size).toBe(2); + }); + + it("falls back to empty-string bucket for paths missing a repo", () => { + const out = groupPathsByRepoName(["unknown.txt"], new Map()); + expect(out.get("")).toEqual(["unknown.txt"]); + }); + + it("preserves single-repo callers (everything in one bucket)", () => { + const lookup = new Map([ + ["a.ts", "only"], + ["b.ts", "only"], + ]); + const out = groupPathsByRepoName(["a.ts", "b.ts"], lookup); + expect(out.size).toBe(1); + expect(out.get("only")).toEqual(["a.ts", "b.ts"]); + }); +}); diff --git a/apps/web/hooks/domains/session/use-session-git-status.ts b/apps/web/hooks/domains/session/use-session-git-status.ts index ae721a2bab..17832707e8 100644 --- a/apps/web/hooks/domains/session/use-session-git-status.ts +++ b/apps/web/hooks/domains/session/use-session-git-status.ts @@ -1,11 +1,15 @@ -import { useEffect } from "react"; +import { useEffect, useMemo } from "react"; import { useShallow } from "zustand/react/shallow"; import { useAppStore } from "@/components/state-provider"; import { getWebSocketClient } from "@/lib/ws/connection"; +import type { GitStatusEntry } from "@/lib/state/slices/session-runtime/types"; /** * Hook to get the current git status for a session. * Git status is keyed by environment ID so sessions sharing an environment share git state. + * + * For multi-repo workspaces this returns whichever repo's status arrived last; + * use useSessionGitStatusByRepo when the caller needs all repos at once. */ export function useSessionGitStatus(sessionId: string | null) { const gitStatus = useAppStore( @@ -37,3 +41,30 @@ export function useSessionGitStatus(sessionId: string | null) { return gitStatus; } + +/** + * Hook to get per-repository git statuses for a multi-repo session. + * Returns an array of { repository_name, status } sorted by repo name. + * + * For single-repo workspaces returns a single-element array (or empty when + * no status has arrived yet). The Changes panel uses this to merge files + * from all repos and tag each with its repository, so the file tree's + * existing per-repo grouping (Phase 6) kicks in automatically. + */ +export function useSessionGitStatusByRepo( + sessionId: string | null, +): Array<{ repository_name: string; status: GitStatusEntry }> { + const map = useAppStore( + useShallow((state) => { + if (!sessionId) return undefined; + const envKey = state.environmentIdBySessionId[sessionId] ?? sessionId; + return state.gitStatus.byEnvironmentRepo[envKey]; + }), + ); + return useMemo(() => { + if (!map) return []; + return Object.entries(map) + .map(([name, status]) => ({ repository_name: name, status })) + .sort((a, b) => a.repository_name.localeCompare(b.repository_name)); + }, [map]); +} diff --git a/apps/web/hooks/domains/session/use-session-git.ts b/apps/web/hooks/domains/session/use-session-git.ts index ff65b2d747..120fba00b8 100644 --- a/apps/web/hooks/domains/session/use-session-git.ts +++ b/apps/web/hooks/domains/session/use-session-git.ts @@ -1,7 +1,7 @@ "use client"; -import { useState, useCallback, useEffect, useMemo } from "react"; -import { useSessionGitStatus } from "./use-session-git-status"; +import { useState, useCallback, useEffect, useMemo, useRef } from "react"; +import { useSessionGitStatus, useSessionGitStatusByRepo } from "./use-session-git-status"; import { useSessionCommits } from "./use-session-commits"; import { useCumulativeDiff } from "./use-cumulative-diff"; import { useGitOperations } from "@/hooks/use-git-operations"; @@ -10,9 +10,36 @@ import type { SessionCommit, CumulativeDiff, } from "@/lib/state/slices/session-runtime/types"; -import type { GitOperationResult, PRCreateResult } from "@/hooks/use-git-operations"; +import type { + GitOperationResult as RawGitOperationResult, + PRCreateResult, +} from "@/hooks/use-git-operations"; -export type { GitOperationResult, PRCreateResult }; +/** + * Per-repo result emitted by frontend-side fan-outs (commit, push, pull, + * rebase, merge, abort, stage-all, unstage-all). Each entry mirrors the wire + * shape returned by agentctl for one repo so the toast layer can describe + * partial successes ("succeeded in [A], failed in [B]: …"). + */ +export type PerRepoOperationResult = { + repository_name: string; + success: boolean; + output: string; + error?: string; +}; + +/** + * Aggregated result of a multi-repo fan-out. The `success` / `output` / + * `error` fields preserve the legacy `GitOperationResult` shape so existing + * single-repo callers keep working unchanged. The optional `per_repo` field + * is frontend-internal — it never goes back to the backend wire and only + * appears when the op was fanned out across more than one repo. + */ +export type GitOperationResult = RawGitOperationResult & { + per_repo?: PerRepoOperationResult[]; +}; + +export type { PRCreateResult }; export type SessionGit = { // Branch info @@ -47,104 +74,602 @@ export type SessionGit = { loadingOperation: string | null; pendingStageFiles: Set; - // Actions - pull: (rebase?: boolean) => Promise; - push: (options?: { force?: boolean; setUpstream?: boolean }) => Promise; - rebase: (baseBranch: string) => Promise; - merge: (baseBranch: string) => Promise; - abort: (operation: "merge" | "rebase") => Promise; - commit: (message: string, stageAll?: boolean, amend?: boolean) => Promise; - stage: (paths?: string[]) => Promise; - stageFile: (paths: string[]) => Promise; + // Actions. The optional `repo` param scopes the op to a single repo + // subpath in multi-repo workspaces; omit for single-repo. + // When `repo` is omitted in multi-repo mode, these fan out across every repo + // (push gates on ahead > 0; pull/rebase/merge/abort hit every repo). + pull: (rebase?: boolean, repo?: string) => Promise; + push: ( + options?: { force?: boolean; setUpstream?: boolean }, + repo?: string, + ) => Promise; + rebase: (baseBranch: string, repo?: string) => Promise; + merge: (baseBranch: string, repo?: string) => Promise; + abort: (operation: "merge" | "rebase", repo?: string) => Promise; + /** Distinct repo names present in the session's files; empty for single-repo. */ + repoNames: string[]; + /** Per-repo branch / ahead / behind / hasStaged for header buttons. */ + perRepoStatus: Array<{ + repository_name: string; + branch: string | null; + ahead: number; + behind: number; + hasStaged: boolean; + hasUnstaged: boolean; + }>; + // Multi-repo: when `repo` is omitted, commit fans out one call per repo with + // staged changes so each repo gets its own commit. With `repo`, only that + // repo is committed. + commit: ( + message: string, + stageAll?: boolean, + amend?: boolean, + repo?: string, + ) => Promise; + // Multi-repo: when `repo` is provided, the op runs against that repo only — + // use this for per-file actions to avoid path-based lookup collisions + // (same-named files like README.md exist in multiple repos). When `repo` + // is omitted, the op falls back to a path-based lookup against allFiles + // (works for unique paths) or fans out across every repo present. + stage: (paths?: string[], repo?: string) => Promise; + stageFile: (paths: string[], repo?: string) => Promise; stageAll: () => Promise; - unstage: (paths?: string[]) => Promise; - unstageFile: (paths: string[]) => Promise; + unstage: (paths?: string[], repo?: string) => Promise; + unstageFile: (paths: string[], repo?: string) => Promise; unstageAll: () => Promise; - discard: (paths?: string[]) => Promise; - revertCommit: (commitSHA: string) => Promise; + discard: (paths?: string[], repo?: string) => Promise; + revertCommit: (commitSHA: string, repo?: string) => Promise; renameBranch: (newName: string) => Promise; - reset: (commitSHA: string, mode: "soft" | "hard") => Promise; + reset: (commitSHA: string, mode: "soft" | "hard", repo?: string) => Promise; createPR: ( title: string, body: string, baseBranch?: string, draft?: boolean, + repo?: string, ) => Promise; }; +/** + * Groups paths into per-repo buckets using a path → repository_name lookup. + * Paths missing a known repo land under "" (the single-repo bucket) so legacy + * single-repo workspaces and stray entries stay correct. Insertion order in + * `paths` is preserved within each bucket. + * + * Exported for testing — also used internally by useSessionGit's stage/unstage + * fan-out, where every per-repo bucket becomes one agentctl call. + */ +export function groupPathsByRepoName( + paths: string[], + repoForPath: Map, +): Map { + const buckets = new Map(); + for (const p of paths) { + const repo = repoForPath.get(p) ?? ""; + const list = buckets.get(repo); + if (list) list.push(p); + else buckets.set(repo, [p]); + } + return buckets; +} + +/** + * Builds the SessionGit's flat file list. For multi-repo workspaces it + * stamps each FileInfo with its repository_name so consumers can group; + * for single-repo it returns the legacy single-status files unchanged. + */ +function aggregateFilesAcrossRepos( + statusByRepo: ReturnType, + gitStatus: ReturnType, +): FileInfo[] { + if (statusByRepo.length > 0) { + const out: FileInfo[] = []; + for (const { repository_name, status } of statusByRepo) { + if (!status?.files) continue; + for (const f of Object.values(status.files)) { + out.push(repository_name ? { ...f, repository_name } : f); + } + } + return out; + } + return gitStatus?.files ? Object.values(gitStatus.files) : []; +} + +type StageDispatchArgs = { + gitOps: ReturnType; + repoForPath: Map; + reposInFiles: string[]; + stagedFiles: FileInfo[]; + setPendingStageFiles: React.Dispatch>>; +}; + +/** + * Encodes a (repo, path) pair as a single Set entry. We need a per-repo key + * because two repos can have files at the same relative path (README.md, + * .gitignore, etc.) and a flat path-only Set would conflate them. The + * separator "::" keeps the key trivially parseable for debugging — repo + * names don't contain "::". + */ +export function pendingKey(repo: string | undefined, path: string): string { + return `${repo ?? ""}::${path}`; +} + +/** + * Aggregates a list of per-repo results into a single GitOperationResult. + * - `success` is true only if every repo succeeded + * - `output` is the joined output across repos (one line each, prefixed with the repo name) + * - `error` is the first failure's error + * - `per_repo` carries the full per-repo breakdown for the toast layer + * + * Single-repo (one entry) returns the raw result with no `per_repo` field so + * the toast handler renders the legacy single-line message. + */ +function aggregatePerRepoResults( + perRepo: PerRepoOperationResult[], + operation: string, +): GitOperationResult { + if (perRepo.length === 0) { + return { success: true, operation, output: "" }; + } + if (perRepo.length === 1) { + const only = perRepo[0]; + return { + success: only.success, + operation, + output: only.output, + error: only.error, + }; + } + const allSucceeded = perRepo.every((r) => r.success); + const firstFailure = perRepo.find((r) => !r.success); + const joined = perRepo + .map((r) => `[${r.repository_name || "default"}] ${r.output}`.trim()) + .filter(Boolean) + .join("\n"); + return { + success: allSucceeded, + operation, + output: joined, + error: firstFailure?.error, + per_repo: perRepo, + }; +} + +/** + * Runs `op` against each repo in `repos`, collecting per-repo results. + * Continues past failures so partial-success state is surfaced (instead of + * stopping at the first error and leaving the user blind to repo A having + * already mutated). + */ +async function fanOutAcrossRepos( + repos: string[], + operation: string, + op: (repo: string) => Promise, +): Promise { + const perRepo: PerRepoOperationResult[] = []; + for (const repo of repos) { + try { + const r = await op(repo); + perRepo.push({ + repository_name: repo, + success: r.success, + output: r.output, + error: r.error, + }); + } catch (e) { + perRepo.push({ + repository_name: repo, + success: false, + output: "", + error: e instanceof Error ? e.message : String(e), + }); + } + } + return aggregatePerRepoResults(perRepo, operation); +} + +/** + * Multi-repo dispatch for stage/unstage/commit/discard. The fan-out logic + * (stage-all across every repo, commit per-repo with staged changes, etc.) + * lives here so the parent hook stays small. + */ +// eslint-disable-next-line max-lines-per-function -- dispatch table, splitting further hurts readability +function useStageDispatch({ + gitOps, + repoForPath, + reposInFiles, + stagedFiles, + setPendingStageFiles, +}: StageDispatchArgs) { + const groupPathsByRepo = useCallback( + (paths: string[]): Map => groupPathsByRepoName(paths, repoForPath), + [repoForPath], + ); + // stage-all / unstage-all fan out across every repo with files. Per-repo + // failures are collected and surfaced via `per_repo` instead of overwriting + // each iteration (Bug 3) — the toast layer renders partial-success cleanly. + const stageAll = useCallback( + async (): Promise => { + if (reposInFiles.length <= 1) return gitOps.stage(undefined, reposInFiles[0]); + return fanOutAcrossRepos(reposInFiles, "stage", (r) => gitOps.stage(undefined, r)); + }, + // eslint-disable-next-line react-hooks/exhaustive-deps -- stable fn ref + [reposInFiles, gitOps.stage], + ); + const unstageAll = useCallback( + async (): Promise => { + if (reposInFiles.length <= 1) return gitOps.unstage(undefined, reposInFiles[0]); + return fanOutAcrossRepos(reposInFiles, "unstage", (r) => gitOps.unstage(undefined, r)); + }, + // eslint-disable-next-line react-hooks/exhaustive-deps -- stable fn ref + [reposInFiles, gitOps.unstage], + ); + // commit fan-out: when no `repo` is given and multiple repos have staged + // changes, commit each one. Bug 2: previously we stopped at first failure + // and returned only that result, so a partial success ("repo A committed, + // repo B failed") looked like a total failure. Now we continue and + // aggregate, preserving each per-repo result for the toast layer. + const commit = useCallback( + async ( + message: string, + stageAllOpt: boolean = true, + amend: boolean = false, + repo?: string, + ): Promise => { + if (repo !== undefined) return gitOps.commit(message, stageAllOpt, amend, repo || undefined); + const reposWithStaged = Array.from( + new Set(stagedFiles.map((f) => f.repository_name).filter((n): n is string => Boolean(n))), + ); + if (reposWithStaged.length === 0) return gitOps.commit(message, stageAllOpt, amend); + if (reposWithStaged.length === 1) { + return gitOps.commit(message, stageAllOpt, amend, reposWithStaged[0]); + } + return fanOutAcrossRepos(reposWithStaged, "commit", (r) => + gitOps.commit(message, stageAllOpt, amend, r), + ); + }, + // eslint-disable-next-line react-hooks/exhaustive-deps -- stable fn ref + [gitOps.commit, stagedFiles], + ); + const runPerRepo = useCallback( + async ( + paths: string[], + explicitRepo: string | undefined, + op: (paths: string[], repo: string | undefined) => Promise, + ): Promise => { + if (explicitRepo !== undefined) return op(paths, explicitRepo || undefined); + const buckets = groupPathsByRepo(paths); + let last: GitOperationResult | undefined; + for (const [repo, repoPaths] of buckets) last = await op(repoPaths, repo || undefined); + return last as GitOperationResult; + }, + [groupPathsByRepo], + ); + const wrapPending = useCallback( + async ( + paths: string[], + repo: string | undefined, + op: (rp: string[], r: string | undefined) => Promise, + ) => { + // Bug 6: key pending entries by `repo::path` so an in-flight stage in + // repo B isn't cleared when repo A's status update lands. The consumer + // `FileRow` checks membership via the same encoding. + const buckets = repo !== undefined ? new Map([[repo, paths]]) : groupPathsByRepo(paths); + const keys: string[] = []; + for (const [r, rp] of buckets) for (const p of rp) keys.push(pendingKey(r, p)); + setPendingStageFiles((prev) => { + const next = new Set(prev); + for (const k of keys) next.add(k); + return next; + }); + try { + return await runPerRepo(paths, repo, op); + } catch (err) { + setPendingStageFiles((prev) => { + const next = new Set(prev); + for (const k of keys) next.delete(k); + return next; + }); + throw err; + } + }, + [runPerRepo, setPendingStageFiles, groupPathsByRepo], + ); + const stageFile = useCallback( + async (paths: string[], repo?: string) => + wrapPending(paths, repo, (rp, r) => gitOps.stage(rp, r)), + // eslint-disable-next-line react-hooks/exhaustive-deps -- stable fn ref + [gitOps.stage, wrapPending], + ); + const unstageFile = useCallback( + async (paths: string[], repo?: string) => + wrapPending(paths, repo, (rp, r) => gitOps.unstage(rp, r)), + // eslint-disable-next-line react-hooks/exhaustive-deps -- stable fn ref + [gitOps.unstage, wrapPending], + ); + const discard = useCallback( + async (paths?: string[], repo?: string) => { + if (!paths || paths.length === 0) return gitOps.discard(paths, repo); + return runPerRepo(paths, repo, (rp, r) => gitOps.discard(rp, r)); + }, + // eslint-disable-next-line react-hooks/exhaustive-deps -- stable fn ref + [gitOps.discard, runPerRepo], + ); + return { stageAll, unstageAll, commit, stageFile, unstageFile, discard }; +} + +/** + * Multi-repo summary for the per-repo Pull/Push/Commit controls. Returns the + * full list of repo names known to the session (even ones with no file + * changes) plus a per-repo `branch / ahead / behind / hasStaged / hasUnstaged` + * row for each. Empty for single-repo workspaces. + */ +function useMultiRepoSummary( + statusByRepo: ReturnType, + allFiles: FileInfo[], + reposInFiles: string[], +) { + // Include every repo known to the session, including the empty-name entry + // for single-repo. Components render the same per-repo group structure in + // both modes — the empty name routes ops to the workspace root, named + // entries route to their respective subdirectories. + // + // Defensive filter: when there are named entries (multi-repo), drop the + // empty entry. The bare task-root tracker is supposed to stay quiet for + // multi-repo (see workspace_tracker.go's gitIndexPath guard) but a stale + // entry can linger from older builds or a bugged code path; without this + // filter the dropdown would show an extra "Repository" / primary-name row + // alongside the real per-repo entries. + const repoNamesForControls = useMemo(() => { + const seen = new Set(); + for (const { repository_name } of statusByRepo) seen.add(repository_name); + for (const r of reposInFiles) seen.add(r); + const all = Array.from(seen).sort((a, b) => a.localeCompare(b)); + const named = all.filter((r) => r !== ""); + return named.length > 0 ? named : all; + }, [statusByRepo, reposInFiles]); + + const perRepoStatus = useMemo(() => { + if (statusByRepo.length === 0) return []; + const stagedByRepo = new Map(); + const unstagedByRepo = new Map(); + for (const f of allFiles) { + const r = f.repository_name ?? ""; + if (f.staged) stagedByRepo.set(r, true); + else unstagedByRepo.set(r, true); + } + const hasNamed = statusByRepo.some((s) => s.repository_name !== ""); + const filtered = hasNamed ? statusByRepo.filter((s) => s.repository_name !== "") : statusByRepo; + return filtered.map(({ repository_name, status }) => ({ + repository_name, + branch: status?.branch ?? null, + ahead: status?.ahead ?? 0, + behind: status?.behind ?? 0, + hasStaged: stagedByRepo.get(repository_name) ?? false, + hasUnstaged: unstagedByRepo.get(repository_name) ?? false, + })); + }, [statusByRepo, allFiles]); + + return { repoNamesForControls, perRepoStatus }; +} + +/** + * Bug 6: clears pending stage markers per-repo, not globally. Each repo's + * status streams in independently as agentctl finishes per-repo ops; if we + * cleared the whole pending Set on any allFiles change, an in-flight stage + * in repo B would lose its spinner the moment repo A finished. Detects which + * repos' status entry changed since the last render and only drops the + * corresponding `${repo}::${path}` keys. + */ +function usePerRepoPendingClear( + statusByRepo: ReturnType, + allFiles: FileInfo[], + setPendingStageFiles: React.Dispatch>>, +) { + const prevStatusRef = useRef>(new Map()); + useEffect(() => { + const next = new Map(); + const refreshed: string[] = []; + for (const { repository_name, status } of statusByRepo) { + next.set(repository_name, status); + if (prevStatusRef.current.get(repository_name) !== status) { + refreshed.push(repository_name); + } + } + // Single-repo / legacy path: gitStatus changed and there's no per-repo + // entry to track. Clear all pending — original behavior, safe because + // there's only one in-flight op at a time in single-repo. + const isLegacySingleRepo = statusByRepo.length === 0; + prevStatusRef.current = next; + if (refreshed.length === 0 && !isLegacySingleRepo) return; + setPendingStageFiles((prev) => { + if (prev.size === 0) return prev; + if (isLegacySingleRepo) return new Set(); + const out = new Set(); + for (const key of prev) { + // key shape is `${repo}::${path}`; first "::" splits repo from path. + const sep = key.indexOf("::"); + const repo = sep === -1 ? "" : key.slice(0, sep); + if (!refreshed.includes(repo)) out.add(key); + } + return out; + }); + }, [allFiles, statusByRepo, setPendingStageFiles]); +} + +type RemoteOpsArgs = { + gitOps: ReturnType; + repoNamesForControls: string[]; + perRepoStatus: Array<{ repository_name: string; ahead: number }>; +}; + +/** + * Fan-out wrappers for push/pull/rebase/merge/abort so the top-bar buttons + * (which call `git.push()` / `git.pull()` etc. without a `repo` arg) hit + * every repo in multi-repo workspaces instead of silently failing at the + * workspace root (Bug 1). Single-repo workspaces and explicit-repo callers + * fall through to the underlying `gitOps` directly. + * + * Push gates on `ahead > 0` per repo so we don't push repos with nothing to + * push. Pull/Rebase/Merge/Abort fan out across every repo unconditionally. + */ +function useRemoteOpsFanOut({ gitOps, repoNamesForControls, perRepoStatus }: RemoteOpsArgs) { + // Filter out the legacy empty-name entry; it represents the workspace root + // which isn't a real git repo in multi-repo task workspaces. We only want + // to iterate the named repos. + const namedRepos = useMemo( + () => repoNamesForControls.filter((r) => r !== ""), + [repoNamesForControls], + ); + const isMultiRepo = namedRepos.length > 1; + const aheadByRepo = useMemo(() => { + const m = new Map(); + for (const s of perRepoStatus) m.set(s.repository_name, s.ahead); + return m; + }, [perRepoStatus]); + + const pull = useCallback( + async (rebase = false, repo?: string): Promise => { + if (repo !== undefined || !isMultiRepo) return gitOps.pull(rebase, repo); + return fanOutAcrossRepos(namedRepos, "pull", (r) => gitOps.pull(rebase, r)); + }, + // eslint-disable-next-line react-hooks/exhaustive-deps -- stable fn ref + [gitOps.pull, isMultiRepo, namedRepos], + ); + + const push = useCallback( + async ( + options?: { force?: boolean; setUpstream?: boolean }, + repo?: string, + ): Promise => { + if (repo !== undefined || !isMultiRepo) return gitOps.push(options, repo); + const reposWithAhead = namedRepos.filter((r) => (aheadByRepo.get(r) ?? 0) > 0); + if (reposWithAhead.length === 0) { + return { success: true, operation: "push", output: "No commits to push" }; + } + return fanOutAcrossRepos(reposWithAhead, "push", (r) => gitOps.push(options, r)); + }, + // eslint-disable-next-line react-hooks/exhaustive-deps -- stable fn ref + [gitOps.push, isMultiRepo, namedRepos, aheadByRepo], + ); + + const rebase = useCallback( + async (baseBranch: string, repo?: string): Promise => { + if (repo !== undefined || !isMultiRepo) return gitOps.rebase(baseBranch, repo); + return fanOutAcrossRepos(namedRepos, "rebase", (r) => gitOps.rebase(baseBranch, r)); + }, + // eslint-disable-next-line react-hooks/exhaustive-deps -- stable fn ref + [gitOps.rebase, isMultiRepo, namedRepos], + ); + + const merge = useCallback( + async (baseBranch: string, repo?: string): Promise => { + if (repo !== undefined || !isMultiRepo) return gitOps.merge(baseBranch, repo); + return fanOutAcrossRepos(namedRepos, "merge", (r) => gitOps.merge(baseBranch, r)); + }, + // eslint-disable-next-line react-hooks/exhaustive-deps -- stable fn ref + [gitOps.merge, isMultiRepo, namedRepos], + ); + + const abort = useCallback( + async (operation: "merge" | "rebase", repo?: string): Promise => { + if (repo !== undefined || !isMultiRepo) return gitOps.abort(operation, repo); + return fanOutAcrossRepos(namedRepos, "abort", (r) => gitOps.abort(operation, r)); + }, + // eslint-disable-next-line react-hooks/exhaustive-deps -- stable fn ref + [gitOps.abort, isMultiRepo, namedRepos], + ); + + return { pull, push, rebase, merge, abort }; +} + +/** + * Bundles the file/multirepo derivations the parent hook needs: aggregated + * file list, staged/unstaged splits, per-path repo lookup, distinct repo + * names, and the multi-repo summary used by per-repo header buttons. + */ +function useFileDerivations( + statusByRepo: ReturnType, + gitStatus: ReturnType, +) { + const allFiles = useMemo( + () => aggregateFilesAcrossRepos(statusByRepo, gitStatus), + [statusByRepo, gitStatus], + ); + const unstagedFiles = useMemo(() => allFiles.filter((f) => !f.staged), [allFiles]); + const stagedFiles = useMemo(() => allFiles.filter((f) => f.staged), [allFiles]); + const repoForPath = useMemo(() => { + const m = new Map(); + for (const f of allFiles) { + if (f.repository_name) m.set(f.path, f.repository_name); + } + return m; + }, [allFiles]); + const reposInFiles = useMemo(() => { + const seen = new Set(); + for (const f of allFiles) if (f.repository_name) seen.add(f.repository_name); + return Array.from(seen); + }, [allFiles]); + const { repoNamesForControls, perRepoStatus } = useMultiRepoSummary( + statusByRepo, + allFiles, + reposInFiles, + ); + return { + allFiles, + unstagedFiles, + stagedFiles, + repoForPath, + reposInFiles, + repoNamesForControls, + perRepoStatus, + }; +} + export function useSessionGit(sessionId: string | null | undefined): SessionGit { const sid = sessionId ?? null; const gitStatus = useSessionGitStatus(sid); + const statusByRepo = useSessionGitStatusByRepo(sid); const { commits, loading: commitsLoading } = useSessionCommits(sid); const { diff: cumulativeDiff } = useCumulativeDiff(sid); const gitOps = useGitOperations(sid); - const [pendingStageFiles, setPendingStageFiles] = useState>(new Set()); - - const allFiles = useMemo( - () => (gitStatus?.files ? Object.values(gitStatus.files) : []), - [gitStatus], - ); - const unstagedFiles = useMemo(() => allFiles.filter((f) => !f.staged), [allFiles]); - const stagedFiles = useMemo(() => allFiles.filter((f) => f.staged), [allFiles]); - - // Clear pending indicators when git status updates (files changed) - useEffect(() => { - if (pendingStageFiles.size > 0) setPendingStageFiles(new Set()); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [allFiles]); - + const { + allFiles, + unstagedFiles, + stagedFiles, + repoForPath, + reposInFiles, + repoNamesForControls, + perRepoStatus, + } = useFileDerivations(statusByRepo, gitStatus); + usePerRepoPendingClear(statusByRepo, allFiles, setPendingStageFiles); const ahead = gitStatus?.ahead ?? 0; const hasUnstaged = unstagedFiles.length > 0; const hasStaged = stagedFiles.length > 0; const hasCommits = commits.length > 0; - // eslint-disable-next-line react-hooks/exhaustive-deps -- depend on stable fn ref, not the whole gitOps object - const stageAll = useCallback(async () => gitOps.stage(), [gitOps.stage]); - // eslint-disable-next-line react-hooks/exhaustive-deps -- depend on stable fn ref, not the whole gitOps object - const unstageAll = useCallback(async () => gitOps.unstage(), [gitOps.unstage]); - - const stageFile = useCallback( - async (paths: string[]) => { - for (const p of paths) setPendingStageFiles((prev) => new Set(prev).add(p)); - try { - return await gitOps.stage(paths); - } catch (err) { - setPendingStageFiles((prev) => { - const next = new Set(prev); - for (const p of paths) next.delete(p); - return next; - }); - throw err; - } - }, - // eslint-disable-next-line react-hooks/exhaustive-deps -- depend on stable fn ref, not the whole gitOps object - [gitOps.stage], - ); - - const unstageFile = useCallback( - async (paths: string[]) => { - for (const p of paths) setPendingStageFiles((prev) => new Set(prev).add(p)); - try { - return await gitOps.unstage(paths); - } catch (err) { - setPendingStageFiles((prev) => { - const next = new Set(prev); - for (const p of paths) next.delete(p); - return next; - }); - throw err; - } - }, - // eslint-disable-next-line react-hooks/exhaustive-deps -- depend on stable fn ref, not the whole gitOps object - [gitOps.unstage], - ); + const stageOps = useStageDispatch({ + gitOps, + repoForPath, + reposInFiles, + stagedFiles, + setPendingStageFiles, + }); + const { stageAll, unstageAll, commit, stageFile, unstageFile, discard } = stageOps; + const remoteOps = useRemoteOpsFanOut({ + gitOps, + repoNamesForControls, + perRepoStatus, + }); return { branch: gitStatus?.branch ?? null, remoteBranch: gitStatus?.remote_branch ?? null, ahead, behind: gitStatus?.behind ?? 0, + repoNames: repoNamesForControls, + perRepoStatus, allFiles, unstagedFiles, @@ -168,19 +693,31 @@ export function useSessionGit(sessionId: string | null | undefined): SessionGit loadingOperation: gitOps.loadingOperation, pendingStageFiles, - pull: gitOps.pull, - push: gitOps.push, - rebase: gitOps.rebase, - merge: gitOps.merge, - abort: gitOps.abort, - commit: gitOps.commit, - stage: gitOps.stage, + pull: remoteOps.pull, + push: remoteOps.push, + rebase: remoteOps.rebase, + merge: remoteOps.merge, + abort: remoteOps.abort, + commit, + // stage/unstage with no paths and a `repo` arg = stage-all/unstage-all + // for that single repo (one agentctl call). Without `repo`, we fan out + // across every repo with files (multi-repo) or hit the workspace root + // (single-repo). With paths, we route to the right repo per file. + stage: (paths?: string[], repo?: string) => { + if (paths && paths.length > 0) return stageFile(paths, repo); + if (repo) return gitOps.stage(undefined, repo); + return stageAll(); + }, stageFile, stageAll, - unstage: gitOps.unstage, + unstage: (paths?: string[], repo?: string) => { + if (paths && paths.length > 0) return unstageFile(paths, repo); + if (repo) return gitOps.unstage(undefined, repo); + return unstageAll(); + }, unstageFile, unstageAll, - discard: gitOps.discard, + discard, revertCommit: gitOps.revertCommit, renameBranch: gitOps.renameBranch, reset: gitOps.reset, diff --git a/apps/web/hooks/domains/workspace/use-repository-branches.ts b/apps/web/hooks/domains/workspace/use-repository-branches.ts index 05bf41f5c0..28e8003cb8 100644 --- a/apps/web/hooks/domains/workspace/use-repository-branches.ts +++ b/apps/web/hooks/domains/workspace/use-repository-branches.ts @@ -1,90 +1,100 @@ +"use client"; + import { useCallback, useEffect, useRef } from "react"; import { useAppStore } from "@/components/state-provider"; -import { listRepositoryBranches } from "@/lib/api"; +import { listBranches, listRepositoryBranches } from "@/lib/api"; import type { Branch } from "@/lib/types/http"; const EMPTY_BRANCHES: Branch[] = []; -// Stale-while-revalidate: when the cache is older than this we fire a -// background refresh on hook mount/repo change. The backend additionally -// enforces a per-repo cooldown so this is the soft, client-side check. -const CLIENT_STALE_MS = 60_000; +/** + * Source of branches for a row: either a workspace repo (by id) or an + * on-machine folder (by path). Both routes go through one backend endpoint + * (`/workspaces/:id/branches`) and share one Zustand cache slice — id-based + * entries are keyed by the repo id, path-based entries get a synthetic key. + * + * `workspaceId` is always required because the route segment needs it. + */ +export type BranchSource = + | { kind: "id"; workspaceId: string; repositoryId: string } + | { kind: "path"; workspaceId: string; path: string }; -export function useRepositoryBranches(repositoryId: string | null, enabled = true) { +function cacheKeyFor(source: BranchSource | null): string { + if (!source) return ""; + return source.kind === "id" ? source.repositoryId : `path::${source.workspaceId}::${source.path}`; +} + +/** + * Loads git branches for a workspace repo or an on-machine path. One hook, + * one cache, one backend endpoint — the source shape decides which query + * param goes on the wire and which key the cache uses. + */ +export type UseBranchesResult = { + branches: Branch[]; + isLoading: boolean; + /** + * Force-refreshes the branch list with a `git fetch` first. Only available + * for id-based sources (workspace-imported repos); on-machine path sources + * resolve to `undefined` since the refresh endpoint takes a repository id. + */ + refresh?: () => Promise; +}; + +export function useBranches(source: BranchSource | null, enabled = true): UseBranchesResult { + const key = cacheKeyFor(source); const branches = useAppStore((state) => - repositoryId - ? (state.repositoryBranches.itemsByRepositoryId[repositoryId] ?? EMPTY_BRANCHES) - : EMPTY_BRANCHES, + key ? (state.repositoryBranches.itemsByRepositoryId[key] ?? EMPTY_BRANCHES) : EMPTY_BRANCHES, ); const isLoaded = useAppStore((state) => - repositoryId ? (state.repositoryBranches.loadedByRepositoryId[repositoryId] ?? false) : false, + key ? (state.repositoryBranches.loadedByRepositoryId[key] ?? false) : false, ); const isLoading = useAppStore((state) => - repositoryId ? (state.repositoryBranches.loadingByRepositoryId[repositoryId] ?? false) : false, - ); - const fetchedAt = useAppStore((state) => - repositoryId ? state.repositoryBranches.fetchedAtByRepositoryId[repositoryId] : undefined, - ); - const fetchError = useAppStore((state) => - repositoryId ? state.repositoryBranches.fetchErrorByRepositoryId[repositoryId] : undefined, + key ? (state.repositoryBranches.loadingByRepositoryId[key] ?? false) : false, ); const setRepositoryBranches = useAppStore((state) => state.setRepositoryBranches); const setRepositoryBranchesLoading = useAppStore((state) => state.setRepositoryBranchesLoading); - const setRepositoryBranchesFetchError = useAppStore( - (state) => state.setRepositoryBranchesFetchError, - ); - const inFlightRef = useRef(null); - - // Stable fetcher closed over the store actions; the repo id is a parameter - // so the same identity works for both mount-time fetches and refresh(). - const runFetch = useCallback( - (repoId: string, refresh: boolean) => { - if (inFlightRef.current === repoId) return; - inFlightRef.current = repoId; - setRepositoryBranchesLoading(repoId, true); - listRepositoryBranches(repoId, { refresh }, { cache: "no-store" }) - .then((response) => { - setRepositoryBranches(repoId, response.branches, { - fetchedAt: response.fetched_at, - fetchError: response.fetch_error, - }); - }) - .catch((err: unknown) => { - // Stale-while-revalidate: keep any previously-cached branches in - // place on transport failure so the dropdown stays usable. Only the - // fetchError is updated so callers can surface the failure. - const message = err instanceof Error ? err.message : "request failed"; - setRepositoryBranchesFetchError(repoId, message); - }) - .finally(() => { - if (inFlightRef.current === repoId) inFlightRef.current = null; - setRepositoryBranchesLoading(repoId, false); - }); - }, - [setRepositoryBranches, setRepositoryBranchesLoading, setRepositoryBranchesFetchError], - ); + const inFlightRef = useRef(false); useEffect(() => { - if (!enabled || !repositoryId) return; - // Cold load: nothing in the cache, do a foreground fetch with refresh so - // the user sees fresh remote branches the first time the dialog opens. - if (!isLoaded) { - runFetch(repositoryId, true); - return; - } - // Warm cache, possibly stale: revalidate in the background. The backend - // cooldown ensures this doesn't hammer git when the dialog is reopened - // in quick succession. - const ageMs = fetchedAt ? Date.now() - new Date(fetchedAt).getTime() : Infinity; - if (ageMs >= CLIENT_STALE_MS) { - runFetch(repositoryId, true); - } - }, [enabled, isLoaded, repositoryId, fetchedAt, runFetch]); + if (!enabled || !source) return; + if (isLoaded || inFlightRef.current) return; + inFlightRef.current = true; + setRepositoryBranchesLoading(key, true); - const refresh = useCallback(() => { - if (!repositoryId) return; - runFetch(repositoryId, true); - }, [repositoryId, runFetch]); + const promise = + source.kind === "id" + ? listBranches(source.workspaceId, { repositoryId: source.repositoryId }) + : listBranches(source.workspaceId, { path: source.path }); - return { branches, isLoading, fetchedAt, fetchError, refresh }; + promise + .then((response) => setRepositoryBranches(key, response.branches)) + .catch(() => setRepositoryBranches(key, [])) + .finally(() => { + inFlightRef.current = false; + setRepositoryBranchesLoading(key, false); + }); + // eslint-disable-next-line react-hooks/exhaustive-deps -- key encodes source identity; listing every field re-fires on every render + }, [enabled, isLoaded, key, setRepositoryBranches, setRepositoryBranchesLoading]); + + const refresh = useCallback(async () => { + if (!source || source.kind !== "id") return; + setRepositoryBranchesLoading(key, true); + try { + const response = await listRepositoryBranches(source.repositoryId, { refresh: true }); + setRepositoryBranches(key, response.branches); + } catch { + // Refresh failures leave the existing branch list in place; the user + // can retry manually. Errors are surfaced via the BranchRefreshButton's + // tooltip when wired with `fetchError`, but the hook does not own + // error state today. + } finally { + setRepositoryBranchesLoading(key, false); + } + }, [source, key, setRepositoryBranches, setRepositoryBranchesLoading]); + + return { + branches, + isLoading, + refresh: source?.kind === "id" ? refresh : undefined, + }; } diff --git a/apps/web/hooks/use-git-operations.ts b/apps/web/hooks/use-git-operations.ts index 1619872701..f3817f685f 100644 --- a/apps/web/hooks/use-git-operations.ts +++ b/apps/web/hooks/use-git-operations.ts @@ -21,24 +21,35 @@ export interface PRCreateResult { } interface UseGitOperationsReturn { - // Operation methods - pull: (rebase?: boolean) => Promise; - push: (options?: { force?: boolean; setUpstream?: boolean }) => Promise; - rebase: (baseBranch: string) => Promise; - merge: (baseBranch: string) => Promise; - abort: (operation: "merge" | "rebase") => Promise; - commit: (message: string, stageAll?: boolean, amend?: boolean) => Promise; - stage: (paths?: string[]) => Promise; - unstage: (paths?: string[]) => Promise; - discard: (paths?: string[]) => Promise; - revertCommit: (commitSHA: string) => Promise; + // Operation methods. The optional `repo` parameter is the multi-repo subpath + // (e.g. "kandev"); pass empty/undefined for single-repo workspaces. Multi-repo + // workspaces MUST scope each call to one repo — bulk callers fan out themselves. + pull: (rebase?: boolean, repo?: string) => Promise; + push: ( + options?: { force?: boolean; setUpstream?: boolean }, + repo?: string, + ) => Promise; + rebase: (baseBranch: string, repo?: string) => Promise; + merge: (baseBranch: string, repo?: string) => Promise; + abort: (operation: "merge" | "rebase", repo?: string) => Promise; + commit: ( + message: string, + stageAll?: boolean, + amend?: boolean, + repo?: string, + ) => Promise; + stage: (paths?: string[], repo?: string) => Promise; + unstage: (paths?: string[], repo?: string) => Promise; + discard: (paths?: string[], repo?: string) => Promise; + revertCommit: (commitSHA: string, repo?: string) => Promise; renameBranch: (newName: string) => Promise; - reset: (commitSHA: string, mode: "soft" | "hard") => Promise; + reset: (commitSHA: string, mode: "soft" | "hard", repo?: string) => Promise; createPR: ( title: string, body: string, baseBranch?: string, draft?: boolean, + repo?: string, ) => Promise; // State @@ -54,60 +65,92 @@ type ExecuteOperation = ( ) => Promise; function buildGitOperationCallbacks(executeOperation: ExecuteOperation) { - const pull = async (rebase = false) => - executeOperation("worktree.pull", { rebase }); + const pull = async (rebase = false, repo?: string) => + executeOperation("worktree.pull", { + rebase, + ...(repo ? { repo } : {}), + }); - const push = async (options?: { force?: boolean; setUpstream?: boolean }) => + const push = async (options?: { force?: boolean; setUpstream?: boolean }, repo?: string) => executeOperation("worktree.push", { force: options?.force ?? false, set_upstream: options?.setUpstream ?? false, + ...(repo ? { repo } : {}), }); - const rebase = async (baseBranch: string) => - executeOperation("worktree.rebase", { base_branch: baseBranch }); + const rebase = async (baseBranch: string, repo?: string) => + executeOperation("worktree.rebase", { + base_branch: baseBranch, + ...(repo ? { repo } : {}), + }); - const merge = async (baseBranch: string) => - executeOperation("worktree.merge", { base_branch: baseBranch }); + const merge = async (baseBranch: string, repo?: string) => + executeOperation("worktree.merge", { + base_branch: baseBranch, + ...(repo ? { repo } : {}), + }); - const abort = async (operation: "merge" | "rebase") => - executeOperation("worktree.abort", { operation }); + const abort = async (operation: "merge" | "rebase", repo?: string) => + executeOperation("worktree.abort", { + operation, + ...(repo ? { repo } : {}), + }); - const commit = async (message: string, stageAll = true, amend = false) => + const commit = async (message: string, stageAll = true, amend = false, repo?: string) => executeOperation("worktree.commit", { message, stage_all: stageAll, amend, + ...(repo ? { repo } : {}), }); - const stage = async (paths?: string[]) => - executeOperation("worktree.stage", { paths: paths ?? [] }); + const stage = async (paths?: string[], repo?: string) => + executeOperation("worktree.stage", { + paths: paths ?? [], + ...(repo ? { repo } : {}), + }); - const unstage = async (paths?: string[]) => - executeOperation("worktree.unstage", { paths: paths ?? [] }); + const unstage = async (paths?: string[], repo?: string) => + executeOperation("worktree.unstage", { + paths: paths ?? [], + ...(repo ? { repo } : {}), + }); - const discard = async (paths?: string[]) => - executeOperation("worktree.discard", { paths: paths ?? [] }); + const discard = async (paths?: string[], repo?: string) => + executeOperation("worktree.discard", { + paths: paths ?? [], + ...(repo ? { repo } : {}), + }); - const revertCommit = async (commitSHA: string) => - executeOperation("worktree.revert_commit", { commit_sha: commitSHA }); + const revertCommit = async (commitSHA: string, repo?: string) => + executeOperation("worktree.revert_commit", { + commit_sha: commitSHA, + ...(repo ? { repo } : {}), + }); const renameBranch = async (newName: string) => executeOperation("worktree.rename_branch", { new_name: newName }); - const reset = async (commitSHA: string, mode: "soft" | "hard") => - executeOperation("worktree.reset", { commit_sha: commitSHA, mode }); + const reset = async (commitSHA: string, mode: "soft" | "hard", repo?: string) => + executeOperation("worktree.reset", { + commit_sha: commitSHA, + mode, + ...(repo ? { repo } : {}), + }); const createPR = async ( title: string, body: string, baseBranch?: string, draft?: boolean, + repo?: string, ): Promise => executeOperation("worktree.create_pr", { title, body, base_branch: baseBranch ?? "", draft: draft ?? true, + ...(repo ? { repo } : {}), }); return { diff --git a/apps/web/lib/api/domains/workspace-api.ts b/apps/web/lib/api/domains/workspace-api.ts index 1259073b32..c92c8d1d7d 100644 --- a/apps/web/lib/api/domains/workspace-api.ts +++ b/apps/web/lib/api/domains/workspace-api.ts @@ -37,6 +37,34 @@ export async function listRepositories( return fetchJson(url, options); } +/** + * Lists git branches for a workspace repo. Pass exactly one of `repositoryId` + * (an imported workspace repo) or `path` (an on-machine folder discovered + * but not yet imported). The backend resolves either to an absolute path and + * runs the same `listGitBranches`. Used by the chip row's per-repo branch + * picker which needs to handle both shapes. + */ +export async function listBranches( + workspaceId: string, + source: { repositoryId: string } | { path: string }, + options?: ApiRequestOptions, +) { + const params = new URLSearchParams(); + if ("repositoryId" in source) params.set("repository_id", source.repositoryId); + else params.set("path", source.path); + return fetchJson( + `/api/v1/workspaces/${workspaceId}/branches?${params.toString()}`, + options, + ); +} + +/** + * Lists git branches for an imported workspace repository, scoped by + * repository id only. Supports `refresh=true` to force a `git fetch` before + * returning the list (with the backend's per-repo cooldown applied). Used by + * single-repo flows that already have the repo id and want to drive the + * stale-while-revalidate UI in the dialog. + */ export async function listRepositoryBranches( repositoryId: string, params?: { refresh?: boolean }, diff --git a/apps/web/lib/diff/types.ts b/apps/web/lib/diff/types.ts index d72e41225f..604f33c1b0 100644 --- a/apps/web/lib/diff/types.ts +++ b/apps/web/lib/diff/types.ts @@ -48,7 +48,7 @@ export interface DiffCommentsActions { clearPending: () => void; setEditingComment: (commentId: string | null) => void; markCommentsSent: (commentIds: string[]) => void; - getCommentsForFile: (sessionId: string, filePath: string) => DiffComment[]; + getCommentsForFile: (sessionId: string, filePath: string, repositoryId?: string) => DiffComment[]; getPendingComments: () => DiffComment[]; clearSessionComments: (sessionId: string) => void; } diff --git a/apps/web/lib/group-by-repo.test.ts b/apps/web/lib/group-by-repo.test.ts new file mode 100644 index 0000000000..c4df1ab390 --- /dev/null +++ b/apps/web/lib/group-by-repo.test.ts @@ -0,0 +1,29 @@ +import { describe, it, expect } from "vitest"; +import { groupByRepositoryName } from "./group-by-repo"; + +describe("groupByRepositoryName", () => { + it("buckets by name in first-seen order, preserving item order within each bucket", () => { + const items = [ + { id: "a", repo: "frontend" }, + { id: "b", repo: "backend" }, + { id: "c", repo: "frontend" }, + { id: "d", repo: "backend" }, + ]; + const groups = groupByRepositoryName(items, (i) => i.repo); + expect(groups.map((g) => g.repositoryName)).toEqual(["frontend", "backend"]); + expect(groups[0].items.map((i) => i.id)).toEqual(["a", "c"]); + expect(groups[1].items.map((i) => i.id)).toEqual(["b", "d"]); + }); + + it("collapses missing/undefined names into a single empty-name bucket", () => { + const items = [{ id: "x" }, { id: "y", repo: "named" }, { id: "z" }]; + const groups = groupByRepositoryName(items, (i) => (i as { repo?: string }).repo); + expect(groups.map((g) => g.repositoryName)).toEqual(["", "named"]); + expect(groups[0].items.map((i) => i.id)).toEqual(["x", "z"]); + expect(groups[1].items.map((i) => i.id)).toEqual(["y"]); + }); + + it("returns an empty array for an empty input", () => { + expect(groupByRepositoryName([], () => "x")).toEqual([]); + }); +}); diff --git a/apps/web/lib/group-by-repo.ts b/apps/web/lib/group-by-repo.ts new file mode 100644 index 0000000000..d090653c42 --- /dev/null +++ b/apps/web/lib/group-by-repo.ts @@ -0,0 +1,26 @@ +/** + * Buckets a list into per-repository groups, preserving the original item + * order within each bucket and producing groups in first-seen order. Items + * whose `getName` returns undefined or empty land in a single empty-named + * group so callers can decide whether to render a header for it. + * + * One copy of this lived in three places before — review-diff-list-groups, + * changes-panel-timeline (files), and changes-panel-timeline (commits) — + * differing only in element type. The generic shape collapses them all. + */ +export function groupByRepositoryName( + items: T[], + getName: (item: T) => string | undefined, +): Array<{ repositoryName: string; items: T[] }> { + const order: string[] = []; + const buckets = new Map(); + for (const item of items) { + const name = getName(item) ?? ""; + if (!buckets.has(name)) { + buckets.set(name, []); + order.push(name); + } + buckets.get(name)!.push(item); + } + return order.map((name) => ({ repositoryName: name, items: buckets.get(name)! })); +} diff --git a/apps/web/lib/kanban/map-task.test.ts b/apps/web/lib/kanban/map-task.test.ts index 88dd98f997..c9f33775a8 100644 --- a/apps/web/lib/kanban/map-task.test.ts +++ b/apps/web/lib/kanban/map-task.test.ts @@ -47,7 +47,10 @@ function wsPayload(overrides: Partial = {}): TaskLike { task_id: "task-1", workflow_id: "wf-1", ...BASE_SCALARS, + // Backend WS task.updated emits both the primary repository_id *and* the + // full repositories array so multi-repo state survives WS-driven updates. repository_id: "repo-a", + repositories: [{ repository_id: "repo-a" }], primary_session_id: "session-p", primary_session_state: "RUNNING", session_count: 2, @@ -95,7 +98,7 @@ describe("toKanbanTask — HTTP DTO / WS payload parity", () => { it("missing repository on either shape: repositoryId is undefined", () => { const http = httpDTO({ repositories: undefined }); - const ws = wsPayload({ repository_id: undefined }); + const ws = wsPayload({ repository_id: undefined, repositories: undefined }); expect(toKanbanTask(http).repositoryId).toBeUndefined(); expect(toKanbanTask(ws).repositoryId).toBeUndefined(); }); diff --git a/apps/web/lib/kanban/map-task.ts b/apps/web/lib/kanban/map-task.ts index 8cff76e0a1..7d1ecaf577 100644 --- a/apps/web/lib/kanban/map-task.ts +++ b/apps/web/lib/kanban/map-task.ts @@ -25,7 +25,13 @@ export type TaskLike = { description?: string | null; position?: number; state?: TaskState; - repositories?: Array<{ repository_id: string }>; + repositories?: Array<{ + id?: string; + repository_id: string; + base_branch?: string; + checkout_branch?: string; + position?: number; + }>; repository_id?: string; primary_session_id?: string | null; primary_session_state?: TaskSessionState | string | null; @@ -49,6 +55,19 @@ function pickId(source: TaskLike): string { return (source.id ?? source.task_id ?? "") as string; } +type KanbanTaskRepository = NonNullable[number]; + +function pickRepositories(source: TaskLike): KanbanTaskRepository[] | undefined { + if (!source.repositories) return undefined; + return source.repositories.map((r, idx) => ({ + id: r.id ?? "", + repository_id: r.repository_id, + base_branch: r.base_branch ?? "", + checkout_branch: r.checkout_branch, + position: r.position ?? idx, + })); +} + /** * Build a canonical {@link KanbanTask} from either an HTTP DTO or a WebSocket * payload. Both paths share this helper so a single publisher change can never @@ -64,6 +83,7 @@ export function toKanbanTask(source: TaskLike): KanbanTask { position: source.position ?? 0, state: source.state, repositoryId: pickRepositoryId(source), + repositories: pickRepositories(source), primarySessionId: source.primary_session_id ?? undefined, primarySessionState: source.primary_session_state ?? undefined, sessionCount: source.session_count ?? undefined, diff --git a/apps/web/lib/ssr/mapper.ts b/apps/web/lib/ssr/mapper.ts index ccff7b2229..6cc403f01c 100644 --- a/apps/web/lib/ssr/mapper.ts +++ b/apps/web/lib/ssr/mapper.ts @@ -1,4 +1,5 @@ import type { AppState, KanbanState } from "@/lib/state/store"; +import { primaryTaskRepository } from "@/lib/types/http"; import type { WorkflowSnapshot, Message, Task } from "@/lib/types/http"; import { isPRReviewFromMetadata, @@ -26,6 +27,7 @@ export function snapshotToState(snapshot: WorkflowSnapshot): Partial { .map((task) => { const workflowStepId = task.workflow_step_id; if (!workflowStepId) return null; + const primary = primaryTaskRepository(task.repositories); return { id: task.id, workflowStepId, @@ -33,7 +35,14 @@ export function snapshotToState(snapshot: WorkflowSnapshot): Partial { description: task.description ?? undefined, position: task.position ?? 0, state: task.state, - repositoryId: task.repositories?.[0]?.repository_id ?? undefined, + repositoryId: primary?.repository_id ?? undefined, + repositories: task.repositories?.map((r) => ({ + id: r.id, + repository_id: r.repository_id, + base_branch: r.base_branch, + checkout_branch: r.checkout_branch, + position: r.position, + })), primarySessionId: task.primary_session_id ?? undefined, primarySessionState: task.primary_session_state ?? undefined, sessionCount: task.session_count ?? undefined, diff --git a/apps/web/lib/state/dockview-panel-actions.ts b/apps/web/lib/state/dockview-panel-actions.ts index 84eb87ef52..fb3c36d4dd 100644 --- a/apps/web/lib/state/dockview-panel-actions.ts +++ b/apps/web/lib/state/dockview-panel-actions.ts @@ -275,15 +275,19 @@ function buildFileDiffAction(get: StoreGet) { } function buildCommitDetailAction(get: StoreGet) { - return (sha: string, opts?: OpenPanelOpts & { groupId?: string }) => { + return (sha: string, opts?: OpenPanelOpts & { groupId?: string; repo?: string }) => { const { api, centerGroupId } = get(); if (!api) return; + // Multi-repo: scope the panel id by repo so the same SHA from two repos + // (rare in practice, but cheap to be correct) doesn't collide and so the + // existing-tab dedup doesn't reuse the wrong-repo's panel. + const itemId = opts?.repo ? `${opts.repo}:${sha}` : sha; openOrReplacePreview({ api, type: "commit-detail", - itemId: sha, + itemId, title: sha.slice(0, 7), - params: { commitSha: sha }, + params: { commitSha: sha, repo: opts?.repo }, groupId: opts?.groupId ?? centerGroupId, quiet: opts?.quiet, pin: opts?.pin, @@ -421,14 +425,19 @@ export function buildExtraPanelActions(get: StoreGet) { opts?.quiet ?? false, ); }, - addPRPanel: () => { + addPRPanel: (prKey?: string) => { const { api, centerGroupId } = get(); if (!api) return; + // Multi-repo: each TaskPR opens in its own panel keyed by + // owner/repo/pr_number so multiple PRs can be tabbed side-by-side. + // Legacy single-repo callers (no key) get the historical panel id. + const id = prKey ? `pr-detail|${prKey}` : "pr-detail"; focusOrAddPanel(api, { - id: "pr-detail", + id, component: "pr-detail", title: "Pull Request", position: { referenceGroup: centerGroupId }, + params: prKey ? { prKey } : undefined, }); }, addTerminalPanel: (terminalId?: string, groupId?: string, environmentId?: string) => { diff --git a/apps/web/lib/state/dockview-store.ts b/apps/web/lib/state/dockview-store.ts index b337dcfc91..9eb0816b6e 100644 --- a/apps/web/lib/state/dockview-store.ts +++ b/apps/web/lib/state/dockview-store.ts @@ -103,14 +103,18 @@ type DockviewStore = { path: string, opts?: OpenPanelOpts & { content?: string; groupId?: string }, ) => void; - addCommitDetailPanel: (sha: string, opts?: OpenPanelOpts & { groupId?: string }) => void; + addCommitDetailPanel: ( + sha: string, + opts?: OpenPanelOpts & { groupId?: string; repo?: string }, + ) => void; addFileEditorPanel: (path: string, name: string, opts?: OpenPanelOpts) => void; promotePreviewToPinned: (type: PreviewType) => void; addBrowserPanel: (url?: string, groupId?: string) => void; addVscodePanel: () => void; openInternalVscode: (goto_: { file: string; line: number; col: number } | null) => void; addPlanPanel: (opts?: { groupId?: string; quiet?: boolean; inCenter?: boolean }) => void; - addPRPanel: () => void; + /** Open a PR detail panel. prKey (owner/repo/pr_number) gives multi-repo tasks one tab per PR. */ + addPRPanel: (prKey?: string) => void; addTerminalPanel: (terminalId?: string, groupId?: string, environmentId?: string) => void; selectedDiff: { path: string; content?: string } | null; setSelectedDiff: (diff: { path: string; content?: string } | null) => void; diff --git a/apps/web/lib/state/slices/comments/comments-store.ts b/apps/web/lib/state/slices/comments/comments-store.ts index 9f4b8dbf96..46d69e253b 100644 --- a/apps/web/lib/state/slices/comments/comments-store.ts +++ b/apps/web/lib/state/slices/comments/comments-store.ts @@ -145,15 +145,22 @@ export const useCommentsStore = create()( hydrateSession: (sessionId: string) => set((state) => hydrateSessionInState(state, sessionId)), - getCommentsForFile: (sessionId: string, filePath: string): DiffComment[] => { + getCommentsForFile: ( + sessionId: string, + filePath: string, + repositoryId?: string, + ): DiffComment[] => { const state = get(); const ids = state.bySession[sessionId]; if (!ids) return []; const result: DiffComment[] = []; for (const id of ids) { const comment = state.byId[id]; - if (comment && isDiffComment(comment) && comment.filePath === filePath) - result.push(comment); + if (!comment || !isDiffComment(comment) || comment.filePath !== filePath) continue; + // When a repo is specified, scope to that repo. Comments without a + // repositoryId (legacy) match any repo so existing data keeps showing. + if (repositoryId && comment.repositoryId && comment.repositoryId !== repositoryId) continue; + result.push(comment); } return result; }, diff --git a/apps/web/lib/state/slices/comments/store-multi-repo.test.ts b/apps/web/lib/state/slices/comments/store-multi-repo.test.ts new file mode 100644 index 0000000000..b2efea2d1e --- /dev/null +++ b/apps/web/lib/state/slices/comments/store-multi-repo.test.ts @@ -0,0 +1,66 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { useCommentsStore } from "./comments-store"; +import type { DiffComment } from "./types"; + +const SESSION_ID = "sess"; +const FILE_PATH = "src/app.tsx"; +const REPO_FRONT = "repo-front"; + +function diffComment(overrides: Partial): DiffComment { + return { + id: "c-" + Math.random().toString(36).slice(2), + sessionId: SESSION_ID, + source: "diff", + text: "looks off", + filePath: FILE_PATH, + startLine: 1, + endLine: 1, + side: "additions", + codeContent: "const x = 1;", + createdAt: new Date().toISOString(), + status: "pending", + ...overrides, + }; +} + +describe("getCommentsForFile (multi-repo)", () => { + beforeEach(() => { + useCommentsStore.setState({ + byId: {}, + bySession: {}, + pendingForChat: [], + editingCommentId: null, + }); + }); + + it("filters by repositoryId when provided", () => { + const store = useCommentsStore.getState(); + store.addComment(diffComment({ id: "front", repositoryId: REPO_FRONT })); + store.addComment(diffComment({ id: "back", repositoryId: "repo-back" })); + + const front = useCommentsStore.getState().getCommentsForFile(SESSION_ID, FILE_PATH, REPO_FRONT); + expect(front.map((c) => c.id)).toEqual(["front"]); + + const back = useCommentsStore.getState().getCommentsForFile(SESSION_ID, FILE_PATH, "repo-back"); + expect(back.map((c) => c.id)).toEqual(["back"]); + }); + + it("returns all matching when repositoryId is omitted", () => { + const store = useCommentsStore.getState(); + store.addComment(diffComment({ id: "front", repositoryId: REPO_FRONT })); + store.addComment(diffComment({ id: "back", repositoryId: "repo-back" })); + + const all = useCommentsStore.getState().getCommentsForFile(SESSION_ID, FILE_PATH); + expect(all.map((c) => c.id).sort()).toEqual(["back", "front"]); + }); + + it("legacy comments without repositoryId match any repo filter", () => { + const store = useCommentsStore.getState(); + store.addComment(diffComment({ id: "legacy" })); // no repositoryId + + const result = useCommentsStore + .getState() + .getCommentsForFile(SESSION_ID, FILE_PATH, REPO_FRONT); + expect(result.map((c) => c.id)).toEqual(["legacy"]); + }); +}); diff --git a/apps/web/lib/state/slices/comments/types.ts b/apps/web/lib/state/slices/comments/types.ts index f29f3f82a0..eba9850a5e 100644 --- a/apps/web/lib/state/slices/comments/types.ts +++ b/apps/web/lib/state/slices/comments/types.ts @@ -14,6 +14,12 @@ export type AnnotationSide = "additions" | "deletions"; type CommentBase = { id: string; sessionId: string; + /** + * Repository this comment belongs to. Optional for backwards compat with + * comments persisted before multi-repo support. New comments always set it + * so per-repo filtering works in multi-repo task views. + */ + repositoryId?: string; text: string; createdAt: string; status: "pending" | "sent"; @@ -95,7 +101,13 @@ export type CommentsActions = { markCommentsSent: (commentIds: string[]) => void; clearSessionComments: (sessionId: string) => void; hydrateSession: (sessionId: string) => void; - getCommentsForFile: (sessionId: string, filePath: string) => DiffComment[]; + /** + * Returns diff comments for a file in a session. When repositoryId is + * provided, results are filtered to that repo only (multi-repo support). + * Omitting repositoryId matches comments regardless of repo, preserving + * single-repo callers and legacy localStorage entries. + */ + getCommentsForFile: (sessionId: string, filePath: string, repositoryId?: string) => DiffComment[]; getPendingComments: () => Comment[]; }; diff --git a/apps/web/lib/state/slices/github/github-slice.ts b/apps/web/lib/state/slices/github/github-slice.ts index bc2364619b..d9bba4d22c 100644 --- a/apps/web/lib/state/slices/github/github-slice.ts +++ b/apps/web/lib/state/slices/github/github-slice.ts @@ -41,7 +41,15 @@ function createTaskPRActions( }), setTaskPR: (taskId, pr) => set((draft) => { - draft.taskPRs.byTaskId[taskId] = pr; + // Upsert by repository_id so multi-repo PRs coexist for the same task. + // For legacy rows without a repository_id, match on the empty key (one + // such row per task max), preserving prior single-PR semantics. + const existing = draft.taskPRs.byTaskId[taskId] ?? []; + const repoKey = pr.repository_id ?? ""; + const idx = existing.findIndex((p) => (p.repository_id ?? "") === repoKey); + if (idx >= 0) existing[idx] = pr; + else existing.push(pr); + draft.taskPRs.byTaskId[taskId] = existing; }), removeTaskPR: (taskId) => set((draft) => { diff --git a/apps/web/lib/state/slices/github/types.ts b/apps/web/lib/state/slices/github/types.ts index b32323dd69..1dc75a3c1a 100644 --- a/apps/web/lib/state/slices/github/types.ts +++ b/apps/web/lib/state/slices/github/types.ts @@ -14,7 +14,8 @@ export type GitHubStatusState = { }; export type TaskPRsState = { - byTaskId: Record; + /** Each task may have multiple PRs (one per repository for multi-repo tasks). */ + byTaskId: Record; loaded: boolean; loading: boolean; }; @@ -54,7 +55,7 @@ export type GitHubSliceState = { export type GitHubSliceActions = { setGitHubStatus: (status: GitHubStatus | null) => void; setGitHubStatusLoading: (loading: boolean) => void; - setTaskPRs: (prs: Record) => void; + setTaskPRs: (prs: Record) => void; setTaskPR: (taskId: string, pr: TaskPR) => void; removeTaskPR: (taskId: string) => void; setTaskPRsLoading: (loading: boolean) => void; diff --git a/apps/web/lib/state/slices/kanban/types.ts b/apps/web/lib/state/slices/kanban/types.ts index 8ad09f480a..30ec8671e5 100644 --- a/apps/web/lib/state/slices/kanban/types.ts +++ b/apps/web/lib/state/slices/kanban/types.ts @@ -28,7 +28,20 @@ export type KanbanState = { description?: string; position: number; state?: TaskStatus; + /** Primary repository id (lowest position). Kept for backwards compat. */ repositoryId?: string; + /** + * All repositories linked to the task, ordered by Position. Optional so + * legacy SSR payloads still parse; multi-repo UI consumers should prefer + * this over repositoryId. + */ + repositories?: Array<{ + id: string; + repository_id: string; + base_branch: string; + checkout_branch?: string; + position: number; + }>; primarySessionId?: string | null; primarySessionState?: string | null; sessionCount?: number | null; diff --git a/apps/web/lib/state/slices/session-runtime/git-status-multi-repo.test.ts b/apps/web/lib/state/slices/session-runtime/git-status-multi-repo.test.ts new file mode 100644 index 0000000000..d94d8744a4 --- /dev/null +++ b/apps/web/lib/state/slices/session-runtime/git-status-multi-repo.test.ts @@ -0,0 +1,104 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { create } from "zustand"; +import { immer } from "zustand/middleware/immer"; +import { createSessionRuntimeSlice } from "./session-runtime-slice"; +import type { GitStatusEntry, SessionRuntimeSlice } from "./types"; + +/** + * Builds a status with the given modified files. Both `modified` (the legacy + * string list) and `files` (the FileInfo map used by hasGitStatusChanged for + * change detection) are populated so the slice's same-status guard recognises + * subsequent updates as meaningful changes. + */ +function entry(overrides: { modified: string[]; repository_name?: string }): GitStatusEntry { + const files: GitStatusEntry["files"] = {}; + for (const path of overrides.modified) { + files[path] = { path, status: "modified", staged: false }; + } + return { + branch: "main", + remote_branch: null, + modified: overrides.modified, + added: [], + deleted: [], + untracked: [], + renamed: [], + ahead: 0, + behind: 0, + files, + timestamp: new Date().toISOString() + Math.random(), + repository_name: overrides.repository_name, + }; +} + +const REPO_FRONTEND = "frontend"; +const REPO_BACKEND = "backend"; +const SESSION = "sess"; +const FRONTEND_FILE = "frontend.tsx"; +const BACKEND_FILE = "backend.go"; + +function makeStore() { + return create()( + immer((set, get, store) => createSessionRuntimeSlice(set, get, store)), + ); +} + +describe("session-runtime gitStatus multi-repo routing", () => { + let useStore: ReturnType; + + beforeEach(() => { + useStore = makeStore(); + }); + + it("routes a single status without repository_name into the legacy map only", () => { + useStore.getState().setGitStatus(SESSION, entry({ modified: ["a.ts"] })); + const state = useStore.getState(); + expect(state.gitStatus.byEnvironmentId[SESSION]?.modified).toEqual(["a.ts"]); + // Per-repo map gets an entry under the empty key (mirrors single-repo) so + // consumers that only read byEnvironmentRepo still see it. + expect(Object.keys(state.gitStatus.byEnvironmentRepo[SESSION] ?? {})).toEqual([""]); + }); + + it("routes per-repo statuses into byEnvironmentRepo keyed by repository_name", () => { + useStore + .getState() + .setGitStatus(SESSION, entry({ modified: [FRONTEND_FILE], repository_name: REPO_FRONTEND })); + useStore + .getState() + .setGitStatus(SESSION, entry({ modified: [BACKEND_FILE], repository_name: REPO_BACKEND })); + + const repoMap = useStore.getState().gitStatus.byEnvironmentRepo[SESSION]; + expect(Object.keys(repoMap).sort()).toEqual([REPO_BACKEND, REPO_FRONTEND]); + expect(repoMap[REPO_FRONTEND].modified).toEqual([FRONTEND_FILE]); + expect(repoMap[REPO_BACKEND].modified).toEqual([BACKEND_FILE]); + }); + + it("does NOT overwrite the per-repo map when a sibling repo updates", () => { + useStore + .getState() + .setGitStatus(SESSION, entry({ modified: [FRONTEND_FILE], repository_name: REPO_FRONTEND })); + useStore + .getState() + .setGitStatus(SESSION, entry({ modified: [BACKEND_FILE], repository_name: REPO_BACKEND })); + // Update frontend again — backend must still be there. + useStore + .getState() + .setGitStatus( + SESSION, + entry({ modified: ["frontend2.tsx"], repository_name: REPO_FRONTEND }), + ); + const repoMap = useStore.getState().gitStatus.byEnvironmentRepo[SESSION]; + expect(repoMap[REPO_FRONTEND].modified).toEqual(["frontend2.tsx"]); + expect(repoMap[REPO_BACKEND].modified).toEqual([BACKEND_FILE]); + }); + + it("clearGitStatus drops both maps", () => { + useStore + .getState() + .setGitStatus(SESSION, entry({ modified: ["x.ts"], repository_name: REPO_FRONTEND })); + useStore.getState().clearGitStatus(SESSION); + const state = useStore.getState(); + expect(state.gitStatus.byEnvironmentId[SESSION]).toBeUndefined(); + expect(state.gitStatus.byEnvironmentRepo[SESSION]).toBeUndefined(); + }); +}); diff --git a/apps/web/lib/state/slices/session-runtime/session-runtime-slice.ts b/apps/web/lib/state/slices/session-runtime/session-runtime-slice.ts index e85a201192..ac794534e1 100644 --- a/apps/web/lib/state/slices/session-runtime/session-runtime-slice.ts +++ b/apps/web/lib/state/slices/session-runtime/session-runtime-slice.ts @@ -77,7 +77,7 @@ export const defaultSessionRuntimeState: SessionRuntimeSliceState = { activeProcessBySessionId: {}, devProcessBySessionId: {}, }, - gitStatus: { byEnvironmentId: {} }, + gitStatus: { byEnvironmentId: {}, byEnvironmentRepo: {} }, environmentIdBySessionId: {}, sessionCommits: { byEnvironmentId: {}, loading: {} }, contextWindow: { bySessionId: {} }, @@ -214,6 +214,7 @@ export function migrateEnvKeyedData( } }; migrate(draft.sessionCommits.byEnvironmentId); + migrate(draft.gitStatus.byEnvironmentRepo); migrate(draft.sessionCommits.loading); migrate(draft.gitStatus.byEnvironmentId); migrate(draft.shell.outputs); @@ -234,14 +235,26 @@ export const createSessionRuntimeSlice: StateCreator< setGitStatus: (sessionId, gitStatus) => set((draft) => { const envKey = draft.environmentIdBySessionId[sessionId] ?? sessionId; + // Multi-repo: when the update is tagged with repository_name, route it + // into the per-repo map. Single-repo updates (no name) keep the legacy + // single-status path; the per-repo map mirrors the same entry under an + // empty key so consumers using only byEnvironmentRepo still see it. + const repoName = gitStatus.repository_name ?? ""; const existing = draft.gitStatus.byEnvironmentId[envKey]; - if (existing && !hasGitStatusChanged(existing, gitStatus)) return; - draft.gitStatus.byEnvironmentId[envKey] = gitStatus; + if (!existing || hasGitStatusChanged(existing, gitStatus)) { + draft.gitStatus.byEnvironmentId[envKey] = gitStatus; + } + const repoMap = (draft.gitStatus.byEnvironmentRepo[envKey] ??= {}); + const existingRepo = repoMap[repoName]; + if (!existingRepo || hasGitStatusChanged(existingRepo, gitStatus)) { + repoMap[repoName] = gitStatus; + } }), clearGitStatus: (sessionId) => set((draft) => { const envKey = draft.environmentIdBySessionId[sessionId] ?? sessionId; delete draft.gitStatus.byEnvironmentId[envKey]; + delete draft.gitStatus.byEnvironmentRepo[envKey]; }), registerSessionEnvironment: (sessionId, environmentId) => set((draft) => { diff --git a/apps/web/lib/state/slices/session-runtime/types.ts b/apps/web/lib/state/slices/session-runtime/types.ts index 0f7e16fe4b..77724932bd 100644 --- a/apps/web/lib/state/slices/session-runtime/types.ts +++ b/apps/web/lib/state/slices/session-runtime/types.ts @@ -49,6 +49,13 @@ export type FileInfo = { old_path?: string; diff?: string; diff_skip_reason?: "too_large" | "binary" | "truncated" | "budget_exceeded"; + /** + * Repository this file belongs to in multi-repo task workspaces. Stamped + * by useSessionGit when aggregating per-repo statuses; empty for single- + * repo workspaces. The Changes panel uses it to group files under + * per-repository headers. + */ + repository_name?: string; }; export type GitStatusEntry = { @@ -65,12 +72,26 @@ export type GitStatusEntry = { timestamp: string | null; branch_additions?: number; branch_deletions?: number; + /** + * Repository this status belongs to in multi-repo task workspaces. Empty + * for single-repo and used as the per-repo key in + * GitStatusState.byEnvironmentRepo. + */ + repository_name?: string; }; export type GitStatusState = { /** Git status keyed by environment ID (shared across sessions in the same environment). - * Falls back to session ID when no environment exists. */ + * Falls back to session ID when no environment exists. + * For multi-repo workspaces this holds the most recently received status + * (whichever repo emitted last); per-repo state lives in byEnvironmentRepo. + */ byEnvironmentId: Record; + /** + * Per-repository git status for multi-repo task workspaces, keyed by + * environment ID then repository name. Empty for single-repo workspaces. + */ + byEnvironmentRepo: Record>; }; // Git Snapshot types for historical tracking @@ -89,6 +110,8 @@ export type SessionCommit = { insertions: number; deletions: number; created_at: string; + /** Multi-repo: name of the repo this commit was made in. Empty for single-repo. */ + repository_name?: string; }; export type CumulativeDiff = { diff --git a/apps/web/lib/state/store.ts b/apps/web/lib/state/store.ts index 58e0e4ac83..0320eb6f6c 100644 --- a/apps/web/lib/state/store.ts +++ b/apps/web/lib/state/store.ts @@ -234,7 +234,7 @@ export type AppState = { // GitHub actions setGitHubStatus: (status: GitHubStatus | null) => void; setGitHubStatusLoading: (loading: boolean) => void; - setTaskPRs: (prs: Record) => void; + setTaskPRs: (prs: Record) => void; setTaskPR: (taskId: string, pr: TaskPR) => void; removeTaskPR: (taskId: string) => void; setTaskPRsLoading: (loading: boolean) => void; diff --git a/apps/web/lib/types/git-events.ts b/apps/web/lib/types/git-events.ts index 9468a06e48..93de9aa61b 100644 --- a/apps/web/lib/types/git-events.ts +++ b/apps/web/lib/types/git-events.ts @@ -24,6 +24,12 @@ export type GitStatusData = { files: Record; branch_additions?: number; branch_deletions?: number; + /** + * Repository this status belongs to in multi-repo task workspaces. + * Empty / undefined for single-repo workspaces. The frontend keys per-repo + * git status off this name so the changes panel can show all repos at once. + */ + repository_name?: string; }; // Git commit data @@ -39,6 +45,8 @@ export type GitCommitData = { deletions: number; committed_at: string; created_at?: string; + /** Multi-repo: name of the repo this commit was made in. Empty for single-repo. */ + repository_name?: string; }; // Git reset data diff --git a/apps/web/lib/types/github.ts b/apps/web/lib/types/github.ts index fdb560fa09..02b898b8da 100644 --- a/apps/web/lib/types/github.ts +++ b/apps/web/lib/types/github.ts @@ -114,6 +114,9 @@ export type MergeableState = export type TaskPR = { id: string; task_id: string; + /** ID of the task repository this PR belongs to. Empty for legacy single-repo + * tasks persisted before multi-repo support. */ + repository_id?: string; owner: string; repo: string; pr_number: number; @@ -209,7 +212,8 @@ export type PRStats = { export type GitHubStatusResponse = GitHubStatus; export type TaskPRsResponse = { - task_prs: Record; + /** Each task may have multiple PRs (one per repository for multi-repo tasks). */ + task_prs: Record; }; export type PRWatchesResponse = { diff --git a/apps/web/lib/types/http.test.ts b/apps/web/lib/types/http.test.ts new file mode 100644 index 0000000000..e0afba8aa2 --- /dev/null +++ b/apps/web/lib/types/http.test.ts @@ -0,0 +1,36 @@ +import { describe, it, expect } from "vitest"; +import { primaryTaskRepository, type TaskRepository } from "./http"; + +function repo(overrides: Partial): TaskRepository { + return { + id: "tr-" + Math.random().toString(36).slice(2), + task_id: "task-1", + repository_id: "repo-x", + base_branch: "main", + position: 0, + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + ...overrides, + }; +} + +describe("primaryTaskRepository", () => { + it("returns undefined for empty list", () => { + expect(primaryTaskRepository(undefined)).toBeUndefined(); + expect(primaryTaskRepository([])).toBeUndefined(); + }); + + it("picks lowest-position repo regardless of array order", () => { + const result = primaryTaskRepository([ + repo({ repository_id: "back", position: 1 }), + repo({ repository_id: "front", position: 0 }), + repo({ repository_id: "shared", position: 2 }), + ]); + expect(result?.repository_id).toBe("front"); + }); + + it("returns the only entry for a single-repo task", () => { + const result = primaryTaskRepository([repo({ repository_id: "only", position: 5 })]); + expect(result?.repository_id).toBe("only"); + }); +}); diff --git a/apps/web/lib/types/http.ts b/apps/web/lib/types/http.ts index 3c8104114e..cb34ecfda4 100644 --- a/apps/web/lib/types/http.ts +++ b/apps/web/lib/types/http.ts @@ -217,12 +217,34 @@ export type TaskRepository = { task_id: string; repository_id: string; base_branch: string; + /** + * Optional branch to fetch and check out after worktree creation + * (e.g. a PR head branch). Empty when no specific branch is requested. + */ + checkout_branch?: string; position: number; metadata?: Record; created_at: string; updated_at: string; }; +/** + * Returns the primary task repository (lowest Position, first by created_at on + * tie). Returns undefined for repo-less tasks. Consumers that historically + * picked `task.repositories?.[0]` should call this to get position-aware + * selection consistent with the backend. + */ +export function primaryTaskRepository( + repos: TaskRepository[] | undefined, +): TaskRepository | undefined { + if (!repos || repos.length === 0) return undefined; + let primary = repos[0]; + for (const r of repos) { + if (r.position < primary.position) primary = r; + } + return primary; +} + export type Task = { id: string; workspace_id: string; diff --git a/apps/web/lib/ws/handlers/git-status.ts b/apps/web/lib/ws/handlers/git-status.ts index d02ef7755e..0967a8bc76 100644 --- a/apps/web/lib/ws/handlers/git-status.ts +++ b/apps/web/lib/ws/handlers/git-status.ts @@ -39,6 +39,9 @@ const gitEventHandlers: GitEventHandlers = { timestamp: event.timestamp, branch_additions: event.status.branch_additions, branch_deletions: event.status.branch_deletions, + // Multi-repo workspaces tag each status with the repository it belongs to; + // setGitStatus routes the entry into byEnvironmentRepo accordingly. + repository_name: event.status.repository_name, }); // Invalidate cumulative diff cache when files change invalidateCumulativeDiffCache(resolveEnvKey(store, event.session_id)); @@ -58,6 +61,8 @@ const gitEventHandlers: GitEventHandlers = { deletions: event.commit.deletions, committed_at: event.commit.committed_at, created_at: event.commit.created_at ?? event.timestamp, + // Multi-repo: tag the commit so the Commits panel can group per repo. + repository_name: event.commit.repository_name, }); // Invalidate cumulative diff cache when new commit is created invalidateCumulativeDiffCache(resolveEnvKey(store, event.session_id)); diff --git a/apps/web/lib/ws/workspace-files.ts b/apps/web/lib/ws/workspace-files.ts index ec1a5b12d1..0586435dea 100644 --- a/apps/web/lib/ws/workspace-files.ts +++ b/apps/web/lib/ws/workspace-files.ts @@ -22,33 +22,40 @@ export async function requestFileTree( } /** - * Request file content from the backend + * Request file content from the backend. + * `repo` is the multi-repo subpath (e.g. "kandev"); omit for single-repo + * workspaces. When set, `path` is interpreted relative to that repo. */ export async function requestFileContent( client: WebSocketClient, sessionId: string, path: string, + repo?: string, ): Promise { return client.request("workspace.file.get", { session_id: sessionId, path, + ...(repo ? { repo } : {}), }); } /** * Request file content at a specific git ref (branch, commit, HEAD, etc.) * Used for diff expansion to fetch old/base version of a file. + * `repo` scopes to a per-repo subdirectory for multi-repo workspaces. */ export async function requestFileContentAtRef( client: WebSocketClient, sessionId: string, path: string, ref: string, + repo?: string, ): Promise { return client.request("workspace.file.get_at_ref", { session_id: sessionId, path, ref, + ...(repo ? { repo } : {}), }); } @@ -80,12 +87,19 @@ export type FileUpdateResponse = { }; /** - * Update file content using a diff + * Update file content using a diff. `repo` scopes to a per-repo subdirectory + * for multi-repo workspaces; omit for single-repo. */ export async function updateFileContent( client: WebSocketClient, sessionId: string, - params: { path: string; diff: string; originalHash: string; desiredContent?: string }, + params: { + path: string; + diff: string; + originalHash: string; + desiredContent?: string; + repo?: string; + }, ): Promise { return client.request("workspace.file.update", { session_id: sessionId, @@ -95,6 +109,7 @@ export async function updateFileContent( ...(params.desiredContent !== undefined && { desired_content: params.desiredContent, }), + ...(params.repo ? { repo: params.repo } : {}), }); } @@ -108,16 +123,19 @@ export type FileCreateResponse = { }; /** - * Create a new file in the workspace + * Create a new file in the workspace. `repo` scopes the create to a per-repo + * subdirectory for multi-repo task workspaces; omit for single-repo. */ export async function createFile( client: WebSocketClient, sessionId: string, path: string, + repo?: string, ): Promise { return client.request("workspace.file.create", { session_id: sessionId, path, + ...(repo ? { repo } : {}), }); } @@ -131,16 +149,19 @@ export type FileDeleteResponse = { }; /** - * Delete a file from the workspace + * Delete a file from the workspace. `repo` scopes the delete to a per-repo + * subdirectory for multi-repo task workspaces; omit for single-repo. */ export async function deleteFile( client: WebSocketClient, sessionId: string, path: string, + repo?: string, ): Promise { return client.request("workspace.file.delete", { session_id: sessionId, path, + ...(repo ? { repo } : {}), }); } @@ -155,17 +176,21 @@ export type FileRenameResponse = { }; /** - * Rename a file or directory in the workspace + * Rename a file or directory in the workspace. `repo` scopes BOTH oldPath and + * newPath to a per-repo subdirectory for multi-repo task workspaces; omit + * for single-repo. Cross-repo moves aren't supported. */ export async function renameFile( client: WebSocketClient, sessionId: string, oldPath: string, newPath: string, + repo?: string, ): Promise { return client.request("workspace.file.rename", { session_id: sessionId, old_path: oldPath, new_path: newPath, + ...(repo ? { repo } : {}), }); }