feat: opt-in fresh-branch checkout for local executor task creation (#695)
This commit is contained in:
@@ -389,6 +389,49 @@ func TestLocalPreparer_BaseBranchCheckout(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestLocalPreparer_FreshBranchInputs covers the post-fresh-branch state: the
|
||||
// HTTP layer now persists the new branch name as the task's BaseBranch, so the
|
||||
// preparer must successfully checkout that branch even when it was just
|
||||
// created moments earlier. This is essentially the existing BaseBranch path —
|
||||
// the test exists to catch any regression that breaks resume after a fresh
|
||||
// checkout.
|
||||
func TestLocalPreparer_FreshBranchPersistedAsBaseBranch(t *testing.T) {
|
||||
isolateGitEnv(t)
|
||||
log := newTestLocalLogger()
|
||||
preparer := NewLocalPreparer(log)
|
||||
|
||||
repoDir := initGitRepo(t)
|
||||
env := newIsolatedGitEnv()
|
||||
// Simulate fresh-branch having just created "feature/new" from main.
|
||||
for _, args := range [][]string{
|
||||
{"checkout", "-b", "feature/new"},
|
||||
{"commit", "--allow-empty", "-m", "fresh"},
|
||||
} {
|
||||
cmd := exec.Command("git", args...)
|
||||
cmd.Dir = repoDir
|
||||
cmd.Env = env
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
t.Fatalf("git %v failed: %s", args, out)
|
||||
}
|
||||
}
|
||||
|
||||
req := &EnvPrepareRequest{
|
||||
TaskID: "task-1",
|
||||
RepositoryPath: repoDir,
|
||||
BaseBranch: "feature/new",
|
||||
}
|
||||
result, err := preparer.Prepare(context.Background(), req, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Prepare() error: %v", err)
|
||||
}
|
||||
if !result.Success {
|
||||
t.Fatalf("Prepare() failed: %s", result.ErrorMessage)
|
||||
}
|
||||
if branch := currentBranch(t, repoDir); branch != "feature/new" {
|
||||
t.Fatalf("expected branch 'feature/new', got %q", branch)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalPreparer_CheckoutBranchPriorityOverBaseBranch(t *testing.T) {
|
||||
isolateGitEnv(t)
|
||||
log := newTestLocalLogger()
|
||||
|
||||
@@ -306,8 +306,17 @@ type BranchDTO struct {
|
||||
}
|
||||
|
||||
type RepositoryBranchesResponse struct {
|
||||
Branches []BranchDTO `json:"branches"`
|
||||
Total int `json:"total"`
|
||||
Branches []BranchDTO `json:"branches"`
|
||||
Total int `json:"total"`
|
||||
CurrentBranch string `json:"current_branch,omitempty"`
|
||||
}
|
||||
|
||||
// LocalRepositoryStatusResponse reports current branch + dirty paths for a
|
||||
// local repository on disk (no session required). Used by the task-create
|
||||
// dialog to preflight the fresh-branch flow on local executors.
|
||||
type LocalRepositoryStatusResponse struct {
|
||||
CurrentBranch string `json:"current_branch"`
|
||||
DirtyFiles []string `json:"dirty_files"`
|
||||
}
|
||||
|
||||
type LocalRepositoryDTO struct {
|
||||
|
||||
@@ -39,6 +39,7 @@ func (h *RepositoryHandlers) registerHTTP(router *gin.Engine) {
|
||||
api.POST("/workspaces/:id/repositories", h.httpCreateRepository)
|
||||
api.GET("/workspaces/:id/repositories/discover", h.httpDiscoverRepositories)
|
||||
api.GET("/workspaces/:id/repositories/branches", h.httpListLocalRepositoryBranches)
|
||||
api.GET("/workspaces/:id/repositories/local-status", h.httpLocalRepositoryStatus)
|
||||
api.GET("/workspaces/:id/repositories/validate", h.httpValidateRepositoryPath)
|
||||
api.GET("/repositories/:id", h.httpGetRepository)
|
||||
api.GET("/repositories/:id/branches", h.httpListRepositoryBranches)
|
||||
@@ -166,13 +167,45 @@ func (h *RepositoryHandlers) httpListLocalRepositoryBranches(c *gin.Context) {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list local repository 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[i] = dto.FromBranch(branch)
|
||||
}
|
||||
c.JSON(http.StatusOK, dto.RepositoryBranchesResponse{
|
||||
Branches: dtoBranches,
|
||||
Total: len(dtoBranches),
|
||||
Branches: dtoBranches,
|
||||
Total: len(dtoBranches),
|
||||
CurrentBranch: current,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *RepositoryHandlers) httpLocalRepositoryStatus(c *gin.Context) {
|
||||
path := c.Query("path")
|
||||
if path == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "path is required"})
|
||||
return
|
||||
}
|
||||
status, err := h.service.LocalRepositoryStatus(c.Request.Context(), 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 read local repository status", zap.Error(err))
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to read local repository status"})
|
||||
return
|
||||
}
|
||||
dirty := status.DirtyFiles
|
||||
if dirty == nil {
|
||||
dirty = []string{}
|
||||
}
|
||||
c.JSON(http.StatusOK, dto.LocalRepositoryStatusResponse{
|
||||
CurrentBranch: status.CurrentBranch,
|
||||
DirtyFiles: dirty,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -240,7 +273,8 @@ func (h *RepositoryHandlers) httpGetRepository(c *gin.Context) {
|
||||
}
|
||||
|
||||
func (h *RepositoryHandlers) httpListRepositoryBranches(c *gin.Context) {
|
||||
branches, err := h.service.ListRepositoryBranches(c.Request.Context(), c.Param("id"))
|
||||
repoID := c.Param("id")
|
||||
branches, err := h.service.ListRepositoryBranches(c.Request.Context(), repoID)
|
||||
if err != nil {
|
||||
if errors.Is(err, service.ErrPathNotAllowed) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "repository path is not allowed"})
|
||||
@@ -250,16 +284,33 @@ func (h *RepositoryHandlers) httpListRepositoryBranches(c *gin.Context) {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list repository branches"})
|
||||
return
|
||||
}
|
||||
current := h.currentBranchForWorkspaceRepo(c.Request.Context(), repoID)
|
||||
dtoBranches := make([]dto.BranchDTO, len(branches))
|
||||
for i, branch := range branches {
|
||||
dtoBranches[i] = dto.FromBranch(branch)
|
||||
}
|
||||
c.JSON(http.StatusOK, dto.RepositoryBranchesResponse{
|
||||
Branches: dtoBranches,
|
||||
Total: len(dtoBranches),
|
||||
Branches: dtoBranches,
|
||||
Total: len(dtoBranches),
|
||||
CurrentBranch: current,
|
||||
})
|
||||
}
|
||||
|
||||
// currentBranchForWorkspaceRepo is best-effort; failures (repo not found,
|
||||
// detached HEAD, IO error) return an empty string so the branches response
|
||||
// still ships.
|
||||
func (h *RepositoryHandlers) currentBranchForWorkspaceRepo(ctx context.Context, repoID string) string {
|
||||
repo, err := h.service.GetRepository(ctx, repoID)
|
||||
if err != nil || repo == nil || repo.LocalPath == "" {
|
||||
return ""
|
||||
}
|
||||
branch, err := h.service.LocalRepositoryCurrentBranch(ctx, repo.LocalPath)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return branch
|
||||
}
|
||||
|
||||
type httpUpdateRepositoryRequest struct {
|
||||
Name *string `json:"name"`
|
||||
SourceType *string `json:"source_type"`
|
||||
|
||||
@@ -2,6 +2,7 @@ package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
@@ -14,6 +15,7 @@ import (
|
||||
"github.com/kandev/kandev/internal/task/dto"
|
||||
"github.com/kandev/kandev/internal/task/models"
|
||||
"github.com/kandev/kandev/internal/task/service"
|
||||
"github.com/kandev/kandev/internal/worktree"
|
||||
v1 "github.com/kandev/kandev/pkg/api/v1"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
@@ -317,6 +319,20 @@ type httpTaskRepositoryInput struct {
|
||||
Name string `json:"name"`
|
||||
DefaultBranch string `json:"default_branch"`
|
||||
GitHubURL string `json:"github_url"`
|
||||
|
||||
// Fresh-branch flow (local executor only): when FreshBranch is true the
|
||||
// handler discards uncommitted changes in the local clone and creates
|
||||
// NewBranchName from BaseBranch before the task is persisted.
|
||||
// ConfirmDiscard must be true if the working tree is dirty; otherwise
|
||||
// the request is rejected with 409 + the dirty file list.
|
||||
// ConsentedDirtyFiles is the dirty-file list the UI showed the user;
|
||||
// the backend rejects with 409 if the live dirty set has any path
|
||||
// that wasn't on this list, protecting against silent loss of files
|
||||
// that became dirty between preflight and execution.
|
||||
FreshBranch bool `json:"fresh_branch,omitempty"`
|
||||
NewBranchName string `json:"new_branch_name,omitempty"`
|
||||
ConfirmDiscard bool `json:"confirm_discard,omitempty"`
|
||||
ConsentedDirtyFiles []string `json:"consented_dirty_files,omitempty"`
|
||||
}
|
||||
|
||||
type httpCreateTaskRequest struct {
|
||||
@@ -444,6 +460,10 @@ func (h *TaskHandlers) httpCreateTask(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
if !h.commitFreshBranch(c, task.ID, title, body.WorkspaceID, body.Repositories, repos) {
|
||||
return
|
||||
}
|
||||
|
||||
taskDTO := dto.FromTask(task)
|
||||
response := createTaskResponse{TaskDTO: taskDTO}
|
||||
// Use the backend-resolved workflow step ID (from the created task) instead of the request's
|
||||
@@ -456,6 +476,158 @@ func (h *TaskHandlers) httpCreateTask(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, response)
|
||||
}
|
||||
|
||||
// commitFreshBranch wraps the post-CreateTask fresh-branch sequence: run the
|
||||
// destructive checkout, compensate by deleting the task if it fails, then
|
||||
// persist the rewritten BaseBranch onto the now-existing task. Returns false
|
||||
// when an HTTP error response was already written.
|
||||
func (h *TaskHandlers) commitFreshBranch(
|
||||
c *gin.Context,
|
||||
taskID, title, workspaceID string,
|
||||
inputs []httpTaskRepositoryInput,
|
||||
repos []dto.TaskRepositoryInput,
|
||||
) bool {
|
||||
hasFresh := false
|
||||
for _, raw := range inputs {
|
||||
if raw.FreshBranch {
|
||||
hasFresh = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasFresh {
|
||||
// No fresh-branch opt-in — repos were already persisted by CreateTask,
|
||||
// so skip the destructive checkout and the DELETE+INSERT rewrite.
|
||||
return true
|
||||
}
|
||||
if !h.applyFreshBranch(c, title, inputs, repos) {
|
||||
if delErr := h.service.DeleteTask(c.Request.Context(), taskID); delErr != nil {
|
||||
h.logger.Warn("failed to compensate by deleting task after fresh-branch failure",
|
||||
zap.String("task_id", taskID), zap.Error(delErr))
|
||||
}
|
||||
return false
|
||||
}
|
||||
// Persist the rewritten BaseBranch (set by applyFreshBranch) onto the task.
|
||||
// applyFreshBranch already mutated the git repo, so we can't roll back —
|
||||
// but we must surface a 5xx so the caller knows the DB still references
|
||||
// the user's original fork point. Otherwise every subsequent session
|
||||
// resume would silently check out the old branch and abandon the new one.
|
||||
if err := h.service.ReplaceTaskRepositories(c.Request.Context(), taskID, workspaceID, convertToServiceRepos(repos)); err != nil {
|
||||
h.logger.Error("failed to persist fresh-branch base branch onto task",
|
||||
zap.String("task_id", taskID), zap.Error(err))
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "fresh branch was created but the task record could not be updated; please check the repository",
|
||||
})
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// applyFreshBranch executes the fresh-branch flow for any local-executor
|
||||
// repository inputs that opted in. Mutates `repos[i].BaseBranch` to the
|
||||
// newly-created branch on success so the persisted task uses it as the
|
||||
// effective base branch on every session resume. Writes the appropriate
|
||||
// HTTP error response and returns false on failure.
|
||||
//
|
||||
// When the caller doesn't supply NewBranchName, the backend generates a
|
||||
// semantic name from the task title (matching the worktree executor's
|
||||
// branch-naming) so the user only has to flip a switch.
|
||||
func (h *TaskHandlers) applyFreshBranch(c *gin.Context, taskTitle string, inputs []httpTaskRepositoryInput, repos []dto.TaskRepositoryInput) bool {
|
||||
ctx := c.Request.Context()
|
||||
for i, raw := range inputs {
|
||||
if !raw.FreshBranch {
|
||||
continue
|
||||
}
|
||||
repoPath, ok := h.resolveLocalRepoPath(c, raw)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
baseBranch := raw.BaseBranch
|
||||
if baseBranch == "" {
|
||||
// User didn't pick one — fall back to the repo's checked-out branch.
|
||||
baseBranch, _ = h.service.LocalRepositoryCurrentBranch(ctx, repoPath)
|
||||
}
|
||||
newBranch := resolveFreshBranchName(raw.NewBranchName, taskTitle)
|
||||
err := h.service.PerformFreshBranch(ctx, service.FreshBranchRequest{
|
||||
RepoPath: repoPath,
|
||||
BaseBranch: baseBranch,
|
||||
NewBranch: newBranch,
|
||||
ConfirmDiscard: raw.ConfirmDiscard,
|
||||
ConsentedDirtyFiles: raw.ConsentedDirtyFiles,
|
||||
})
|
||||
if err != nil {
|
||||
h.respondFreshBranchError(c, err)
|
||||
return false
|
||||
}
|
||||
repos[i].BaseBranch = newBranch
|
||||
repos[i].CheckoutBranch = ""
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// resolveFreshBranchName returns the user-supplied branch name when present,
|
||||
// otherwise generates one from the task title using the same semantic name +
|
||||
// random-suffix scheme as the worktree executor. Returns an empty string only
|
||||
// when both the raw name and the task title would produce nothing useful;
|
||||
// PerformFreshBranch's sanitizeGitRef rejects that downstream.
|
||||
func resolveFreshBranchName(rawNewBranch, taskTitle string) string {
|
||||
if name := strings.TrimSpace(rawNewBranch); name != "" {
|
||||
return name
|
||||
}
|
||||
return worktree.SemanticWorktreeName(taskTitle, worktree.SmallSuffix(3))
|
||||
}
|
||||
|
||||
func (h *TaskHandlers) resolveLocalRepoPath(c *gin.Context, raw httpTaskRepositoryInput) (string, bool) {
|
||||
if raw.LocalPath != "" {
|
||||
return raw.LocalPath, true
|
||||
}
|
||||
if raw.RepositoryID == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "fresh_branch requires repository_id or local_path"})
|
||||
return "", false
|
||||
}
|
||||
repo, err := h.service.GetRepository(c.Request.Context(), raw.RepositoryID)
|
||||
if err != nil || repo == nil || repo.LocalPath == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "repository has no local path"})
|
||||
return "", false
|
||||
}
|
||||
return repo.LocalPath, true
|
||||
}
|
||||
|
||||
func (h *TaskHandlers) respondFreshBranchError(c *gin.Context, err error) {
|
||||
var dirty *service.ErrDirtyWorkingTree
|
||||
if errors.As(err, &dirty) {
|
||||
c.JSON(http.StatusConflict, gin.H{
|
||||
"error": "working tree has uncommitted changes",
|
||||
"dirty_files": dirty.DirtyFiles,
|
||||
})
|
||||
return
|
||||
}
|
||||
if errors.Is(err, service.ErrPathNotAllowed) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "repository path is not within an allowed root"})
|
||||
return
|
||||
}
|
||||
if errors.Is(err, service.ErrInvalidGitRef) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if errors.Is(err, service.ErrFreshBranchCheckout) {
|
||||
// git checkout failure (e.g. branch already exists, base branch unknown).
|
||||
// Surface the underlying message — it tells the user what to fix.
|
||||
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if errors.Is(err, service.ErrPartialDiscard) {
|
||||
// Tracked files already gone, untracked survived. The user needs to
|
||||
// know they have lost work before retrying — never a generic 500.
|
||||
h.logger.Error("partial discard during fresh-branch", zap.Error(err))
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "tracked changes were discarded but git clean failed; untracked files remain — inspect the repo before retrying",
|
||||
"partial": true,
|
||||
})
|
||||
return
|
||||
}
|
||||
h.logger.Error("fresh branch checkout failed", zap.Error(err))
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to prepare fresh branch"})
|
||||
}
|
||||
|
||||
// convertCreateTaskRepositories converts httpTaskRepositoryInput slice to dto.TaskRepositoryInput slice.
|
||||
// Returns (nil, false) and writes a 400 response if any entry is missing both repository_id and local_path.
|
||||
func convertCreateTaskRepositories(c *gin.Context, inputs []httpTaskRepositoryInput) ([]dto.TaskRepositoryInput, bool) {
|
||||
|
||||
@@ -2,6 +2,7 @@ package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
@@ -24,6 +25,63 @@ func newTestLogger(t *testing.T) *logger.Logger {
|
||||
return log
|
||||
}
|
||||
|
||||
func TestResolveFreshBranchName(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
raw string
|
||||
taskTitle string
|
||||
assert func(t *testing.T, got string)
|
||||
}{
|
||||
{
|
||||
name: "uses raw name when provided",
|
||||
raw: "feature/x",
|
||||
taskTitle: "ignored",
|
||||
assert: func(t *testing.T, got string) {
|
||||
if got != "feature/x" {
|
||||
t.Fatalf("expected feature/x, got %q", got)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "trims whitespace from raw name",
|
||||
raw: " feature/y ",
|
||||
taskTitle: "ignored",
|
||||
assert: func(t *testing.T, got string) {
|
||||
if got != "feature/y" {
|
||||
t.Fatalf("expected feature/y, got %q", got)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "derives from title when raw is empty",
|
||||
raw: "",
|
||||
taskTitle: "Fix login bug",
|
||||
assert: func(t *testing.T, got string) {
|
||||
if !strings.HasPrefix(got, "fix-login-bug_") {
|
||||
t.Fatalf("expected fix-login-bug_ prefix, got %q", got)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "title with only special chars falls back to suffix only",
|
||||
raw: "",
|
||||
taskTitle: "!!!",
|
||||
assert: func(t *testing.T, got string) {
|
||||
// SemanticWorktreeName returns just the suffix (3 chars from
|
||||
// the alphabet) when the sanitized title is empty.
|
||||
if len(got) != 3 {
|
||||
t.Fatalf("expected 3-char suffix, got %q (len %d)", got, len(got))
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
tc.assert(t, resolveFreshBranchName(tc.raw, tc.taskTitle))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssociatePRFromRepoInputs(t *testing.T) {
|
||||
log := newTestLogger(t)
|
||||
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// gitRefRe accepts the conservative subset of git ref names we expose to user
|
||||
// input: ASCII letters, digits, and `._/-`, with no leading `-` (which git
|
||||
// would treat as an option). This rejects any value that could be interpreted
|
||||
// as a flag by `git fetch` / `git checkout` even though we pass refs as
|
||||
// positional argv entries.
|
||||
var gitRefRe = regexp.MustCompile(`^[A-Za-z0-9_.][A-Za-z0-9_./-]*$`)
|
||||
|
||||
// ErrInvalidGitRef is returned by sanitizeGitRef when the supplied ref name
|
||||
// doesn't match the allowlist. Returned errors wrap this sentinel so the
|
||||
// HTTP layer can map it to a 400 response while preserving the descriptive
|
||||
// inner message.
|
||||
var ErrInvalidGitRef = errors.New("invalid git ref name")
|
||||
|
||||
// ErrFreshBranchCheckout is returned when the underlying `git checkout`
|
||||
// fails (e.g. the requested NewBranch already exists or BaseBranch can't be
|
||||
// resolved). The caller can map this to a 4xx + the wrapped message.
|
||||
var ErrFreshBranchCheckout = errors.New("checkout failed")
|
||||
|
||||
// ErrPartialDiscard is returned when discarding the working tree partially
|
||||
// succeeded — `git reset --hard` mutated tracked files but `git clean -fd`
|
||||
// then failed (locked file, permission error, etc.) so untracked files
|
||||
// survive. Callers must surface a distinct message: a generic 500 would
|
||||
// hide the fact that some user work has already been destroyed.
|
||||
var ErrPartialDiscard = errors.New("partial discard: tracked changes were reset but git clean failed")
|
||||
|
||||
// FreshBranchRequest performs a destructive checkout on a local repository:
|
||||
// discard uncommitted changes, then create NewBranch from BaseBranch.
|
||||
//
|
||||
// ConsentedDirtyFiles is the dirty-file list the caller already showed to
|
||||
// the user. The backend re-reads dirty files at execution time and rejects
|
||||
// the request (with the new list) if any path appears that wasn't on the
|
||||
// consented list — this protects against silent loss of files that became
|
||||
// dirty between the consent dialog and the actual discard.
|
||||
type FreshBranchRequest struct {
|
||||
RepoPath string
|
||||
BaseBranch string
|
||||
NewBranch string
|
||||
ConfirmDiscard bool
|
||||
ConsentedDirtyFiles []string
|
||||
}
|
||||
|
||||
// ErrDirtyWorkingTree is returned by PerformFreshBranch when the repository
|
||||
// has uncommitted changes and the caller did not set ConfirmDiscard, OR
|
||||
// when the dirty file set grew beyond the consented list. Callers should
|
||||
// surface DirtyFiles to the user and re-issue the request with
|
||||
// ConfirmDiscard=true and ConsentedDirtyFiles=DirtyFiles once consent is
|
||||
// re-confirmed.
|
||||
type ErrDirtyWorkingTree struct {
|
||||
DirtyFiles []string
|
||||
}
|
||||
|
||||
func (e *ErrDirtyWorkingTree) Error() string {
|
||||
return fmt.Sprintf("working tree has %d uncommitted change(s)", len(e.DirtyFiles))
|
||||
}
|
||||
|
||||
// PerformFreshBranch validates the request, optionally discards uncommitted
|
||||
// changes, then creates the new branch from the base branch.
|
||||
//
|
||||
// On success the repository is left checked out on NewBranch. The caller is
|
||||
// responsible for persisting NewBranch as the task's effective base branch so
|
||||
// that future session resumes return to it.
|
||||
func (s *Service) PerformFreshBranch(ctx context.Context, req FreshBranchRequest) error {
|
||||
// Validate refs against an allowlist before we shell out. The returned
|
||||
// values are the trusted strings used for the rest of the function — we
|
||||
// never use req.NewBranch / req.BaseBranch directly past this point.
|
||||
newBranch, err := sanitizeGitRef(req.NewBranch, "new branch")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
baseBranch, err := sanitizeGitRef(req.BaseBranch, "base branch")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
absPath, err := s.resolveAllowedLocalPath(req.RepoPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
dirty, err := readGitDirtyFiles(ctx, absPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(dirty) > 0 {
|
||||
if !req.ConfirmDiscard {
|
||||
return &ErrDirtyWorkingTree{DirtyFiles: dirty}
|
||||
}
|
||||
consented := make(map[string]struct{}, len(req.ConsentedDirtyFiles))
|
||||
for _, p := range req.ConsentedDirtyFiles {
|
||||
consented[p] = struct{}{}
|
||||
}
|
||||
for _, p := range dirty {
|
||||
if _, ok := consented[p]; !ok {
|
||||
return &ErrDirtyWorkingTree{DirtyFiles: dirty}
|
||||
}
|
||||
}
|
||||
if err := discardLocalChanges(ctx, absPath); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Best-effort fetch so a remote-tracking ref like "origin/main" resolves.
|
||||
_, _ = runGit(ctx, absPath, "fetch", "origin", baseBranch)
|
||||
|
||||
// Use `-b` (not `-B`) so we refuse to overwrite an existing branch — that
|
||||
// would silently orphan commits only reachable from it.
|
||||
if out, err := runGit(ctx, absPath, "checkout", "-b", newBranch, baseBranch); err != nil {
|
||||
return fmt.Errorf("%w: %q from %q: %s", ErrFreshBranchCheckout, newBranch, baseBranch, out)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// sanitizeGitRef returns name unchanged when it matches the allowlist, or an
|
||||
// error otherwise. The returned value is the trusted ref string callers
|
||||
// should pass to git.
|
||||
func sanitizeGitRef(name, label string) (string, error) {
|
||||
if name == "" {
|
||||
return "", fmt.Errorf("%w: %s is required", ErrInvalidGitRef, label)
|
||||
}
|
||||
if !gitRefRe.MatchString(name) || strings.Contains(name, "..") {
|
||||
return "", fmt.Errorf("%w: %s %q", ErrInvalidGitRef, label, name)
|
||||
}
|
||||
return name, nil
|
||||
}
|
||||
|
||||
func discardLocalChanges(ctx context.Context, repoPath string) error {
|
||||
if out, err := runGit(ctx, repoPath, "reset", "--hard"); err != nil {
|
||||
return fmt.Errorf("git reset --hard: %w (%s)", err, out)
|
||||
}
|
||||
if out, err := runGit(ctx, repoPath, "clean", "-fd"); err != nil {
|
||||
// reset already mutated tracked files; signal partial state so the
|
||||
// HTTP handler can surface a distinct message instead of a generic 500.
|
||||
return fmt.Errorf("%w: git clean -fd: %v (%s)", ErrPartialDiscard, err, out)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func runGit(ctx context.Context, dir string, args ...string) (string, error) {
|
||||
cmd := exec.CommandContext(ctx, "git", args...)
|
||||
cmd.Dir = dir
|
||||
out, err := cmd.CombinedOutput()
|
||||
return strings.TrimSpace(string(out)), err
|
||||
}
|
||||
@@ -0,0 +1,355 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestPerformFreshBranch_CleanWorkingTree(t *testing.T) {
|
||||
isolateGitEnvForTest(t)
|
||||
root := t.TempDir()
|
||||
repoPath := filepath.Join(root, "repo")
|
||||
initRealGitRepo(t, repoPath)
|
||||
|
||||
svc := newDiscoveryService(t, root)
|
||||
err := svc.PerformFreshBranch(context.Background(), FreshBranchRequest{
|
||||
RepoPath: repoPath,
|
||||
BaseBranch: "main",
|
||||
NewBranch: "feature/x",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("PerformFreshBranch error: %v", err)
|
||||
}
|
||||
if got := readCurrentBranch(t, repoPath); got != "feature/x" {
|
||||
t.Fatalf("expected current branch feature/x, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPerformFreshBranch_DirtyWithoutConfirm(t *testing.T) {
|
||||
isolateGitEnvForTest(t)
|
||||
root := t.TempDir()
|
||||
repoPath := filepath.Join(root, "repo")
|
||||
initRealGitRepo(t, repoPath)
|
||||
writeDirty(t, repoPath, "untracked.txt", "hi")
|
||||
|
||||
svc := newDiscoveryService(t, root)
|
||||
err := svc.PerformFreshBranch(context.Background(), FreshBranchRequest{
|
||||
RepoPath: repoPath,
|
||||
BaseBranch: "main",
|
||||
NewBranch: "feature/x",
|
||||
})
|
||||
var dirty *ErrDirtyWorkingTree
|
||||
if !errors.As(err, &dirty) {
|
||||
t.Fatalf("expected ErrDirtyWorkingTree, got %v", err)
|
||||
}
|
||||
if len(dirty.DirtyFiles) == 0 {
|
||||
t.Fatalf("expected dirty files in error")
|
||||
}
|
||||
// Branch must NOT have changed when caller didn't confirm.
|
||||
if got := readCurrentBranch(t, repoPath); got != "main" {
|
||||
t.Fatalf("expected branch unchanged on rejection, got %q", got)
|
||||
}
|
||||
// Untracked file must NOT have been deleted.
|
||||
if _, err := os.Stat(filepath.Join(repoPath, "untracked.txt")); err != nil {
|
||||
t.Fatalf("expected dirty file preserved, stat err: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPerformFreshBranch_DirtyWithConfirm(t *testing.T) {
|
||||
isolateGitEnvForTest(t)
|
||||
root := t.TempDir()
|
||||
repoPath := filepath.Join(root, "repo")
|
||||
initRealGitRepo(t, repoPath)
|
||||
writeDirty(t, repoPath, "untracked.txt", "hi")
|
||||
|
||||
svc := newDiscoveryService(t, root)
|
||||
err := svc.PerformFreshBranch(context.Background(), FreshBranchRequest{
|
||||
RepoPath: repoPath,
|
||||
BaseBranch: "main",
|
||||
NewBranch: "feature/x",
|
||||
ConfirmDiscard: true,
|
||||
ConsentedDirtyFiles: []string{"untracked.txt"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("PerformFreshBranch error: %v", err)
|
||||
}
|
||||
if got := readCurrentBranch(t, repoPath); got != "feature/x" {
|
||||
t.Fatalf("expected branch feature/x, got %q", got)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(repoPath, "untracked.txt")); !os.IsNotExist(err) {
|
||||
t.Fatalf("expected dirty file removed, got err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPerformFreshBranch_DirtyWithExtraUnconsented(t *testing.T) {
|
||||
isolateGitEnvForTest(t)
|
||||
root := t.TempDir()
|
||||
repoPath := filepath.Join(root, "repo")
|
||||
initRealGitRepo(t, repoPath)
|
||||
writeDirty(t, repoPath, "consented.txt", "hi")
|
||||
writeDirty(t, repoPath, "extra.txt", "hi")
|
||||
|
||||
svc := newDiscoveryService(t, root)
|
||||
err := svc.PerformFreshBranch(context.Background(), FreshBranchRequest{
|
||||
RepoPath: repoPath,
|
||||
BaseBranch: "main",
|
||||
NewBranch: "feature/x",
|
||||
ConfirmDiscard: true,
|
||||
ConsentedDirtyFiles: []string{"consented.txt"},
|
||||
})
|
||||
var dirty *ErrDirtyWorkingTree
|
||||
if !errors.As(err, &dirty) {
|
||||
t.Fatalf("expected ErrDirtyWorkingTree when extras present, got %v", err)
|
||||
}
|
||||
if len(dirty.DirtyFiles) != 2 {
|
||||
t.Fatalf("expected 2 dirty files in error, got %d", len(dirty.DirtyFiles))
|
||||
}
|
||||
// Repo must NOT have been mutated when consent didn't cover all files.
|
||||
if got := readCurrentBranch(t, repoPath); got != "main" {
|
||||
t.Fatalf("expected branch unchanged, got %q", got)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(repoPath, "extra.txt")); err != nil {
|
||||
t.Fatalf("expected extra.txt preserved, got err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPerformFreshBranch_RefusesExistingBranch(t *testing.T) {
|
||||
isolateGitEnvForTest(t)
|
||||
root := t.TempDir()
|
||||
repoPath := filepath.Join(root, "repo")
|
||||
initRealGitRepo(t, repoPath)
|
||||
// Create an existing branch with a unique commit.
|
||||
env := isolatedGitEnv()
|
||||
for _, args := range [][]string{
|
||||
{"checkout", "-b", "feature/existing"},
|
||||
{"commit", "--allow-empty", "-m", "important work"},
|
||||
{"checkout", "main"},
|
||||
} {
|
||||
cmd := exec.Command("git", args...)
|
||||
cmd.Dir = repoPath
|
||||
cmd.Env = env
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
t.Fatalf("git %v failed: %s", args, out)
|
||||
}
|
||||
}
|
||||
|
||||
svc := newDiscoveryService(t, root)
|
||||
err := svc.PerformFreshBranch(context.Background(), FreshBranchRequest{
|
||||
RepoPath: repoPath,
|
||||
BaseBranch: "main",
|
||||
NewBranch: "feature/existing",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected PerformFreshBranch to refuse overwriting an existing branch")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPerformFreshBranch_RejectsFlagLikeRefs(t *testing.T) {
|
||||
isolateGitEnvForTest(t)
|
||||
root := t.TempDir()
|
||||
repoPath := filepath.Join(root, "repo")
|
||||
initRealGitRepo(t, repoPath)
|
||||
|
||||
svc := newDiscoveryService(t, root)
|
||||
if err := svc.PerformFreshBranch(context.Background(), FreshBranchRequest{
|
||||
RepoPath: repoPath, BaseBranch: "main", NewBranch: "--upload-pack=evil",
|
||||
}); err == nil {
|
||||
t.Fatal("expected rejection for ref starting with --")
|
||||
}
|
||||
if err := svc.PerformFreshBranch(context.Background(), FreshBranchRequest{
|
||||
RepoPath: repoPath, BaseBranch: "-flag", NewBranch: "feature/x",
|
||||
}); err == nil {
|
||||
t.Fatal("expected rejection for ref starting with -")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPerformFreshBranch_RejectsEmptyFields(t *testing.T) {
|
||||
isolateGitEnvForTest(t)
|
||||
root := t.TempDir()
|
||||
repoPath := filepath.Join(root, "repo")
|
||||
initRealGitRepo(t, repoPath)
|
||||
|
||||
svc := newDiscoveryService(t, root)
|
||||
if err := svc.PerformFreshBranch(context.Background(), FreshBranchRequest{
|
||||
RepoPath: repoPath, NewBranch: "feature/x",
|
||||
}); err == nil {
|
||||
t.Fatal("expected error for empty BaseBranch")
|
||||
}
|
||||
if err := svc.PerformFreshBranch(context.Background(), FreshBranchRequest{
|
||||
RepoPath: repoPath, BaseBranch: "main",
|
||||
}); err == nil {
|
||||
t.Fatal("expected error for empty NewBranch")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalRepositoryStatus_CleanRepo(t *testing.T) {
|
||||
isolateGitEnvForTest(t)
|
||||
root := t.TempDir()
|
||||
repoPath := filepath.Join(root, "repo")
|
||||
initRealGitRepo(t, repoPath)
|
||||
|
||||
svc := newDiscoveryService(t, root)
|
||||
status, err := svc.LocalRepositoryStatus(context.Background(), repoPath)
|
||||
if err != nil {
|
||||
t.Fatalf("LocalRepositoryStatus error: %v", err)
|
||||
}
|
||||
if status.CurrentBranch != "main" {
|
||||
t.Fatalf("expected current branch main, got %q", status.CurrentBranch)
|
||||
}
|
||||
if len(status.DirtyFiles) != 0 {
|
||||
t.Fatalf("expected clean tree, got dirty files: %v", status.DirtyFiles)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalRepositoryStatus_HandlesSpacesAndRenames(t *testing.T) {
|
||||
isolateGitEnvForTest(t)
|
||||
root := t.TempDir()
|
||||
repoPath := filepath.Join(root, "repo")
|
||||
initRealGitRepo(t, repoPath)
|
||||
env := isolatedGitEnv()
|
||||
// Commit "old name.txt" so we can rename it.
|
||||
writeDirty(t, repoPath, "old name.txt", "hi")
|
||||
for _, args := range [][]string{
|
||||
{"add", "old name.txt"},
|
||||
{"commit", "-m", "add file"},
|
||||
} {
|
||||
cmd := exec.Command("git", args...)
|
||||
cmd.Dir = repoPath
|
||||
cmd.Env = env
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
t.Fatalf("git %v failed: %s", args, out)
|
||||
}
|
||||
}
|
||||
// Stage a rename + add an unicode-named untracked file.
|
||||
for _, args := range [][]string{
|
||||
{"mv", "old name.txt", "renamed name.txt"},
|
||||
} {
|
||||
cmd := exec.Command("git", args...)
|
||||
cmd.Dir = repoPath
|
||||
cmd.Env = env
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
t.Fatalf("git %v failed: %s", args, out)
|
||||
}
|
||||
}
|
||||
writeDirty(t, repoPath, "café.txt", "x")
|
||||
|
||||
svc := newDiscoveryService(t, root)
|
||||
status, err := svc.LocalRepositoryStatus(context.Background(), repoPath)
|
||||
if err != nil {
|
||||
t.Fatalf("LocalRepositoryStatus error: %v", err)
|
||||
}
|
||||
// Expect the rename target ("renamed name.txt") with its space preserved
|
||||
// (no surrounding quotes), and the unicode untracked file un-escaped.
|
||||
wantRename := "renamed name.txt"
|
||||
wantUnicode := "café.txt"
|
||||
got := status.DirtyFiles
|
||||
hasRename := false
|
||||
hasUnicode := false
|
||||
for _, p := range got {
|
||||
if p == wantRename {
|
||||
hasRename = true
|
||||
}
|
||||
if p == wantUnicode {
|
||||
hasUnicode = true
|
||||
}
|
||||
}
|
||||
if !hasRename {
|
||||
t.Fatalf("expected rename target %q in dirty files, got %#v", wantRename, got)
|
||||
}
|
||||
if !hasUnicode {
|
||||
t.Fatalf("expected unicode path %q in dirty files, got %#v", wantUnicode, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalRepositoryStatus_ListsDirtyFiles(t *testing.T) {
|
||||
isolateGitEnvForTest(t)
|
||||
root := t.TempDir()
|
||||
repoPath := filepath.Join(root, "repo")
|
||||
initRealGitRepo(t, repoPath)
|
||||
writeDirty(t, repoPath, "a.txt", "hi")
|
||||
writeDirty(t, repoPath, "b.txt", "hi")
|
||||
|
||||
svc := newDiscoveryService(t, root)
|
||||
status, err := svc.LocalRepositoryStatus(context.Background(), repoPath)
|
||||
if err != nil {
|
||||
t.Fatalf("LocalRepositoryStatus error: %v", err)
|
||||
}
|
||||
if len(status.DirtyFiles) != 2 {
|
||||
t.Fatalf("expected 2 dirty files, got %d: %v", len(status.DirtyFiles), status.DirtyFiles)
|
||||
}
|
||||
}
|
||||
|
||||
// initRealGitRepo creates a git repo with a single commit on `main`.
|
||||
func initRealGitRepo(t *testing.T, dir string) {
|
||||
t.Helper()
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir repo: %v", err)
|
||||
}
|
||||
env := isolatedGitEnv()
|
||||
for _, args := range [][]string{
|
||||
{"init", "-b", "main"},
|
||||
{"config", "core.hooksPath", "/dev/null"},
|
||||
{"commit", "--allow-empty", "-m", "init"},
|
||||
} {
|
||||
cmd := exec.Command("git", args...)
|
||||
cmd.Dir = dir
|
||||
cmd.Env = env
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
t.Fatalf("git %v failed: %s", args, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func readCurrentBranch(t *testing.T, repoPath string) string {
|
||||
t.Helper()
|
||||
cmd := exec.Command("git", "rev-parse", "--abbrev-ref", "HEAD")
|
||||
cmd.Dir = repoPath
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
t.Fatalf("git rev-parse failed: %v", err)
|
||||
}
|
||||
return strings.TrimSpace(string(out))
|
||||
}
|
||||
|
||||
func writeDirty(t *testing.T, repoPath, name, content string) {
|
||||
t.Helper()
|
||||
if err := os.WriteFile(filepath.Join(repoPath, name), []byte(content), 0o644); err != nil {
|
||||
t.Fatalf("write dirty %q: %v", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
func isolatedGitEnv() []string {
|
||||
env := make([]string, 0, len(os.Environ()))
|
||||
for _, e := range os.Environ() {
|
||||
key, _, _ := strings.Cut(e, "=")
|
||||
if strings.HasPrefix(key, "GIT_") {
|
||||
continue
|
||||
}
|
||||
env = append(env, e)
|
||||
}
|
||||
return append(env,
|
||||
"GIT_AUTHOR_NAME=Test",
|
||||
"GIT_AUTHOR_EMAIL=test@test.local",
|
||||
"GIT_COMMITTER_NAME=Test",
|
||||
"GIT_COMMITTER_EMAIL=test@test.local",
|
||||
"GIT_CONFIG_GLOBAL=/dev/null",
|
||||
"GIT_CONFIG_NOSYSTEM=1",
|
||||
)
|
||||
}
|
||||
|
||||
func isolateGitEnvForTest(t *testing.T) {
|
||||
t.Helper()
|
||||
t.Setenv("GIT_CONFIG_GLOBAL", "/dev/null")
|
||||
t.Setenv("GIT_CONFIG_NOSYSTEM", "1")
|
||||
for _, key := range []string{"GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE"} {
|
||||
if val, ok := os.LookupEnv(key); ok {
|
||||
_ = os.Unsetenv(key)
|
||||
t.Cleanup(func() { _ = os.Setenv(key, val) })
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
@@ -16,6 +17,14 @@ type RepositoryDiscoveryConfig struct {
|
||||
MaxDepth int
|
||||
}
|
||||
|
||||
// LocalRepoStatus reports the current branch and dirty file paths for a
|
||||
// local repository on disk. Used by the task-create dialog to preflight the
|
||||
// fresh-branch flow.
|
||||
type LocalRepoStatus struct {
|
||||
CurrentBranch string
|
||||
DirtyFiles []string
|
||||
}
|
||||
|
||||
type LocalRepository struct {
|
||||
Path string
|
||||
Name string
|
||||
@@ -153,26 +162,99 @@ func (s *Service) ListRepositoryBranches(ctx context.Context, repoID string) ([]
|
||||
}
|
||||
|
||||
func (s *Service) ListLocalRepositoryBranches(ctx context.Context, path string) ([]Branch, error) {
|
||||
if path == "" {
|
||||
return nil, fmt.Errorf("repository path is required")
|
||||
}
|
||||
absPath, err := filepath.Abs(path)
|
||||
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)
|
||||
absPath, err := s.resolveAllowedLocalPath(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !info.IsDir() {
|
||||
return nil, fmt.Errorf("repository path is not a directory")
|
||||
}
|
||||
return listGitBranches(absPath)
|
||||
}
|
||||
|
||||
// LocalRepositoryCurrentBranch returns the currently checked-out branch for a
|
||||
// local repository on disk. Returns the branch name (e.g. "main") or an empty
|
||||
// string if HEAD is detached or unreadable.
|
||||
func (s *Service) LocalRepositoryCurrentBranch(ctx context.Context, path string) (string, error) {
|
||||
absPath, err := s.resolveAllowedLocalPath(path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return readGitCurrentBranch(absPath, s.discoveryRoots()), nil
|
||||
}
|
||||
|
||||
// LocalRepositoryStatus returns the current branch and dirty file list for a
|
||||
// local repository on disk. Used by the task-create dialog to preflight the
|
||||
// fresh-branch flow before committing to a destructive checkout.
|
||||
func (s *Service) LocalRepositoryStatus(ctx context.Context, path string) (LocalRepoStatus, error) {
|
||||
absPath, err := s.resolveAllowedLocalPath(path)
|
||||
if err != nil {
|
||||
return LocalRepoStatus{}, err
|
||||
}
|
||||
dirty, err := readGitDirtyFiles(ctx, absPath)
|
||||
if err != nil {
|
||||
return LocalRepoStatus{}, err
|
||||
}
|
||||
return LocalRepoStatus{
|
||||
CurrentBranch: readGitCurrentBranch(absPath, s.discoveryRoots()),
|
||||
DirtyFiles: dirty,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Service) resolveAllowedLocalPath(repoPath string) (string, error) {
|
||||
if repoPath == "" {
|
||||
return "", fmt.Errorf("repository path is required")
|
||||
}
|
||||
roots := s.discoveryRoots()
|
||||
abs, err := filepath.Abs(filepath.Clean(repoPath))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("invalid repository path: %w", err)
|
||||
}
|
||||
safe, err := pathWithinRoots(abs, roots)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
// Resolve symlinks then re-validate so a symlink within a root cannot
|
||||
// escape outside the allowed roots.
|
||||
resolved, err := filepath.EvalSymlinks(safe)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
resolvedSafe, err := pathWithinRoots(filepath.Clean(resolved), roots)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
info, err := os.Stat(resolvedSafe)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !info.IsDir() {
|
||||
return "", fmt.Errorf("repository path is not a directory")
|
||||
}
|
||||
return resolvedSafe, nil
|
||||
}
|
||||
|
||||
// pathWithinRoots returns abs only when it sits inside one of the allowed
|
||||
// roots after resolving relative segments. The returned string is the
|
||||
// trusted, validated path; callers should never use the original input for
|
||||
// subsequent file operations.
|
||||
func pathWithinRoots(abs string, roots []string) (string, error) {
|
||||
for _, root := range roots {
|
||||
if root == "" {
|
||||
continue
|
||||
}
|
||||
rel, err := filepath.Rel(root, abs)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if rel == "." {
|
||||
return abs, nil
|
||||
}
|
||||
if rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) {
|
||||
continue
|
||||
}
|
||||
return abs, nil
|
||||
}
|
||||
return "", ErrPathNotAllowed
|
||||
}
|
||||
|
||||
func (s *Service) discoveryRoots() []string {
|
||||
if len(s.discoveryConfig.Roots) > 0 {
|
||||
return normalizeRoots(s.discoveryConfig.Roots)
|
||||
@@ -354,6 +436,69 @@ func isWithinRoot(path string, root string) bool {
|
||||
return strings.HasPrefix(absPath, absRoot)
|
||||
}
|
||||
|
||||
// readGitCurrentBranch returns the currently checked-out branch by reading
|
||||
// .git/HEAD directly. Returns an empty string if HEAD is detached, the path
|
||||
// is not a clean absolute path, the resolved git dir escapes the allowed
|
||||
// roots, or HEAD is unreadable. We avoid `git rev-parse --abbrev-ref HEAD`
|
||||
// to skip the subprocess cost on the hot path of branch discovery.
|
||||
func readGitCurrentBranch(repoPath string, allowedRoots []string) string {
|
||||
if !filepath.IsAbs(repoPath) {
|
||||
return ""
|
||||
}
|
||||
cleanRepo := filepath.Clean(repoPath)
|
||||
gitDir, err := resolveGitDirWithin(cleanRepo, allowedRoots)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
headPath := filepath.Clean(filepath.Join(gitDir, gitHEAD))
|
||||
if !filepath.IsAbs(headPath) {
|
||||
return ""
|
||||
}
|
||||
content, err := os.ReadFile(headPath)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
trimmed := strings.TrimSpace(string(content))
|
||||
ref, ok := strings.CutPrefix(trimmed, "ref: ")
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimPrefix(ref, "refs/heads/")
|
||||
}
|
||||
|
||||
// readGitDirtyFiles returns the list of dirty file paths in a repository, as
|
||||
// reported by `git status --porcelain=v1 -z`. The `-z` form is NUL-terminated
|
||||
// and disables path quoting, so paths with spaces, unicode, or control chars
|
||||
// round-trip cleanly through the consent flow. Renames (status `R`/`C`)
|
||||
// emit two NUL-separated records: the rename target then the original; we
|
||||
// keep only the target since that's what's currently in the working tree.
|
||||
// Returns an empty slice for a clean working tree.
|
||||
func readGitDirtyFiles(ctx context.Context, repoPath string) ([]string, error) {
|
||||
cmd := exec.CommandContext(ctx, "git", "status", "--porcelain=v1", "-z")
|
||||
cmd.Dir = repoPath
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("git status: %w", err)
|
||||
}
|
||||
entries := strings.Split(strings.TrimRight(string(out), "\x00"), "\x00")
|
||||
var paths []string
|
||||
for i := 0; i < len(entries); i++ {
|
||||
entry := entries[i]
|
||||
if len(entry) < 4 {
|
||||
continue
|
||||
}
|
||||
status := entry[:2]
|
||||
path := entry[3:]
|
||||
paths = append(paths, path)
|
||||
// Rename / copy entries push an extra "old name" record after the
|
||||
// "new name" record in -z mode; consume and skip it.
|
||||
if status[0] == 'R' || status[0] == 'C' {
|
||||
i++
|
||||
}
|
||||
}
|
||||
return paths, nil
|
||||
}
|
||||
|
||||
func readGitDefaultBranch(repoPath string) (string, error) {
|
||||
gitDir, err := resolveGitDir(repoPath)
|
||||
if err != nil {
|
||||
@@ -402,6 +547,43 @@ func resolveGitDir(repoPath string) (string, error) {
|
||||
return filepath.Clean(filepath.Join(repoPath, gitDir)), nil
|
||||
}
|
||||
|
||||
// resolveGitDirWithin is the trust-boundary-aware variant of resolveGitDir:
|
||||
// when `.git` is a file pointer (worktree case), the embedded `gitdir:` line
|
||||
// can point anywhere on disk. This wrapper rejects any resolved gitdir that
|
||||
// is not inside the repo path or one of the allowed discovery roots, so a
|
||||
// crafted `.git` file inside an otherwise-allowed directory cannot make the
|
||||
// caller read from outside the sandbox.
|
||||
func resolveGitDirWithin(repoPath string, allowedRoots []string) (string, error) {
|
||||
gitDir, err := resolveGitDir(repoPath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
// Resolve symlinks before the root check — `repoPath/.git` itself can be
|
||||
// a symlink to a directory outside the sandbox, and a lexical Clean on
|
||||
// the gitdir string would not catch that.
|
||||
resolved, err := filepath.EvalSymlinks(gitDir)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
cleaned := filepath.Clean(resolved)
|
||||
if !filepath.IsAbs(cleaned) {
|
||||
abs, absErr := filepath.Abs(cleaned)
|
||||
if absErr != nil {
|
||||
return "", absErr
|
||||
}
|
||||
cleaned = abs
|
||||
}
|
||||
if isWithinRoot(cleaned, repoPath) {
|
||||
return cleaned, nil
|
||||
}
|
||||
for _, root := range allowedRoots {
|
||||
if root != "" && isWithinRoot(cleaned, root) {
|
||||
return cleaned, nil
|
||||
}
|
||||
}
|
||||
return "", ErrPathNotAllowed
|
||||
}
|
||||
|
||||
func resolveCommonGitDir(gitDir string) string {
|
||||
commonFile := filepath.Join(gitDir, "commondir")
|
||||
content, err := os.ReadFile(commonFile)
|
||||
|
||||
@@ -2,6 +2,7 @@ package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
@@ -203,6 +204,91 @@ func TestResolveGitDir(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// canonicalTempDir returns a t.TempDir() path with symlinks resolved. macOS
|
||||
// returns `/var/folders/...` from t.TempDir() while EvalSymlinks (used inside
|
||||
// resolveGitDirWithin) yields `/private/var/folders/...`; without
|
||||
// canonicalization the test's expected paths and the function's output live in
|
||||
// different namespaces and equality checks fail. On Linux this is a no-op.
|
||||
func canonicalTempDir(t *testing.T) string {
|
||||
t.Helper()
|
||||
resolved, err := filepath.EvalSymlinks(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatalf("eval temp dir: %v", err)
|
||||
}
|
||||
return resolved
|
||||
}
|
||||
|
||||
// TestResolveGitDirWithin_RejectsEscapedGitdir exercises the security-critical
|
||||
// branch of resolveGitDirWithin: a repo whose `.git` is a file pointer
|
||||
// (worktree style) whose embedded `gitdir:` points outside any allowed root.
|
||||
// The function must return ErrPathNotAllowed; if the bounds check is ever
|
||||
// removed, callers like readGitCurrentBranch would read arbitrary files on
|
||||
// disk.
|
||||
func TestResolveGitDirWithin_RejectsEscapedGitdir(t *testing.T) {
|
||||
root := canonicalTempDir(t)
|
||||
outside := canonicalTempDir(t)
|
||||
|
||||
repoPath := filepath.Join(root, "repo")
|
||||
if err := os.MkdirAll(repoPath, 0o755); err != nil {
|
||||
t.Fatalf("mkdir repo: %v", err)
|
||||
}
|
||||
gitFile := filepath.Join(repoPath, ".git")
|
||||
if err := os.WriteFile(gitFile, []byte("gitdir: "+outside+"\n"), 0o644); err != nil {
|
||||
t.Fatalf("write .git pointer: %v", err)
|
||||
}
|
||||
|
||||
if _, err := resolveGitDirWithin(repoPath, []string{root}); !errors.Is(err, ErrPathNotAllowed) {
|
||||
t.Fatalf("expected ErrPathNotAllowed for gitdir outside roots, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolveGitDirWithin_RejectsSymlinkedGitDir covers the case where
|
||||
// `.git` itself is a symlink whose target lives outside the allowed roots.
|
||||
// The lexical path looks fine — it's `<allowed>/repo/.git` — but following
|
||||
// the symlink would read from elsewhere. EvalSymlinks must catch that.
|
||||
func TestResolveGitDirWithin_RejectsSymlinkedGitDir(t *testing.T) {
|
||||
root := canonicalTempDir(t)
|
||||
outside := canonicalTempDir(t)
|
||||
repoPath := filepath.Join(root, "repo")
|
||||
if err := os.MkdirAll(repoPath, 0o755); err != nil {
|
||||
t.Fatalf("mkdir repo: %v", err)
|
||||
}
|
||||
// `.git` symlinks to an out-of-root directory containing a HEAD file.
|
||||
if err := os.Symlink(outside, filepath.Join(repoPath, ".git")); err != nil {
|
||||
t.Skipf("symlink not supported on this platform: %v", err)
|
||||
}
|
||||
|
||||
if _, err := resolveGitDirWithin(repoPath, []string{root}); !errors.Is(err, ErrPathNotAllowed) {
|
||||
t.Fatalf("expected ErrPathNotAllowed for symlinked .git escaping roots, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolveGitDirWithin_AllowsRepoLocalGitFile covers the legitimate
|
||||
// worktree case: `.git` file points to a sibling path that's still inside the
|
||||
// repo (e.g. main repo's `.git/worktrees/<name>`).
|
||||
func TestResolveGitDirWithin_AllowsRepoLocalGitFile(t *testing.T) {
|
||||
repoPath := canonicalTempDir(t)
|
||||
worktreesDir := filepath.Join(repoPath, ".git", "worktrees", "wt")
|
||||
if err := os.MkdirAll(worktreesDir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir worktrees: %v", err)
|
||||
}
|
||||
gitFile := filepath.Join(repoPath, "wt-clone", ".git")
|
||||
if err := os.MkdirAll(filepath.Dir(gitFile), 0o755); err != nil {
|
||||
t.Fatalf("mkdir wt-clone: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(gitFile, []byte("gitdir: "+worktreesDir+"\n"), 0o644); err != nil {
|
||||
t.Fatalf("write .git pointer: %v", err)
|
||||
}
|
||||
|
||||
got, err := resolveGitDirWithin(filepath.Dir(gitFile), []string{repoPath})
|
||||
if err != nil {
|
||||
t.Fatalf("expected legitimate worktree to resolve, got %v", err)
|
||||
}
|
||||
if got != worktreesDir {
|
||||
t.Fatalf("expected gitdir %q, got %q", worktreesDir, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListGitBranches(t *testing.T) {
|
||||
repoPath := t.TempDir()
|
||||
gitDir := filepath.Join(repoPath, ".git")
|
||||
|
||||
@@ -249,6 +249,14 @@ func parseGitHubRepoURL(rawURL string) (owner, name string, err error) {
|
||||
return parts[0], parts[1], nil
|
||||
}
|
||||
|
||||
// ReplaceTaskRepositories deletes all existing task-repository associations
|
||||
// and recreates them. Exported for callers that mutate repository inputs
|
||||
// (e.g. the fresh-branch flow rewriting BaseBranch) after CreateTask has
|
||||
// already persisted the original set.
|
||||
func (s *Service) ReplaceTaskRepositories(ctx context.Context, taskID, workspaceID string, repositories []TaskRepositoryInput) error {
|
||||
return s.replaceTaskRepositories(ctx, taskID, workspaceID, repositories)
|
||||
}
|
||||
|
||||
// replaceTaskRepositories deletes all existing task-repository associations and recreates them.
|
||||
func (s *Service) replaceTaskRepositories(ctx context.Context, taskID, workspaceID string, repositories []TaskRepositoryInput) error {
|
||||
if err := s.taskRepos.DeleteTaskRepositoriesByTask(ctx, taskID); err != nil {
|
||||
|
||||
@@ -10,6 +10,7 @@ import type {
|
||||
RepositoryDiscoveryResponse,
|
||||
ListRepositoriesResponse,
|
||||
RepositoryBranchesResponse,
|
||||
LocalRepositoryStatusResponse,
|
||||
ListRepositoryScriptsResponse,
|
||||
ListWorkspacesResponse,
|
||||
RepositoryPathValidationResponse,
|
||||
@@ -174,6 +175,16 @@ export async function listLocalRepositoryBranchesAction(
|
||||
);
|
||||
}
|
||||
|
||||
export async function getLocalRepositoryStatusAction(
|
||||
workspaceId: string,
|
||||
path: string,
|
||||
): Promise<LocalRepositoryStatusResponse> {
|
||||
const params = `?path=${encodeURIComponent(path)}`;
|
||||
return fetchJson<LocalRepositoryStatusResponse>(
|
||||
`${apiBaseUrl}/api/v1/workspaces/${workspaceId}/repositories/local-status${params}`,
|
||||
);
|
||||
}
|
||||
|
||||
export async function discoverRepositoriesAction(
|
||||
workspaceId: string,
|
||||
root?: string,
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@kandev/ui/alert-dialog";
|
||||
|
||||
const MAX_VISIBLE_FILES = 20;
|
||||
|
||||
export type DiscardLocalChangesDialogProps = {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
dirtyFiles: string[];
|
||||
repoPath?: string;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
};
|
||||
|
||||
export function DiscardLocalChangesDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
dirtyFiles,
|
||||
repoPath,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}: DiscardLocalChangesDialogProps) {
|
||||
const visible = dirtyFiles.slice(0, MAX_VISIBLE_FILES);
|
||||
const overflow = dirtyFiles.length - visible.length;
|
||||
const target = repoPath ? ` in your local clone at ${repoPath}` : " in your local clone";
|
||||
|
||||
return (
|
||||
<AlertDialog open={open} onOpenChange={onOpenChange}>
|
||||
<AlertDialogContent
|
||||
data-testid="discard-local-changes-dialog"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Discard local changes?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Starting this task will permanently discard the uncommitted changes
|
||||
{target}. Back up anything you want to keep before continuing.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<ul
|
||||
className="max-h-48 overflow-auto rounded-md border border-border bg-muted/40 p-2 text-xs font-mono text-muted-foreground space-y-0.5"
|
||||
data-testid="discard-local-changes-files"
|
||||
>
|
||||
{visible.map((path) => (
|
||||
<li key={path} className="truncate" title={path}>
|
||||
{path}
|
||||
</li>
|
||||
))}
|
||||
{overflow > 0 && (
|
||||
<li
|
||||
className="pt-1 text-[11px] italic text-muted-foreground/80"
|
||||
data-testid="discard-local-changes-overflow"
|
||||
>
|
||||
+{overflow} more
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel
|
||||
className="cursor-pointer"
|
||||
data-testid="discard-local-changes-cancel"
|
||||
onClick={onCancel}
|
||||
>
|
||||
Cancel
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className="cursor-pointer bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
data-testid="discard-local-changes-confirm"
|
||||
onClick={onConfirm}
|
||||
>
|
||||
Discard and continue
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
}
|
||||
@@ -7,6 +7,7 @@ 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";
|
||||
@@ -153,7 +154,8 @@ export function useLocalBranchesEffect(
|
||||
workspaceId: string | null,
|
||||
toast: ReturnType<typeof useToast>["toast"],
|
||||
) {
|
||||
const { selectedLocalRepo, setLocalBranches, setLocalBranchesLoading } = fs;
|
||||
const { selectedLocalRepo, setLocalBranches, setLocalBranchesLoading, setCurrentLocalBranch } =
|
||||
fs;
|
||||
useEffect(() => {
|
||||
if (!open || !workspaceId || !selectedLocalRepo) return;
|
||||
const repoPath = selectedLocalRepo.path;
|
||||
@@ -162,6 +164,7 @@ export function useLocalBranchesEffect(
|
||||
.then(() => listLocalRepositoryBranchesAction(workspaceId, repoPath))
|
||||
.then((r) => {
|
||||
setLocalBranches(r.branches);
|
||||
setCurrentLocalBranch(r.current_branch ?? "");
|
||||
})
|
||||
.catch((e) => {
|
||||
toast({
|
||||
@@ -170,11 +173,71 @@ export function useLocalBranchesEffect(
|
||||
variant: "error",
|
||||
});
|
||||
setLocalBranches([]);
|
||||
setCurrentLocalBranch("");
|
||||
})
|
||||
.finally(() => {
|
||||
setLocalBranchesLoading(false);
|
||||
});
|
||||
}, [open, selectedLocalRepo, toast, workspaceId, setLocalBranches, setLocalBranchesLoading]);
|
||||
}, [
|
||||
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.
|
||||
*/
|
||||
export function useCurrentLocalBranchEffect(
|
||||
fs: DialogFormState,
|
||||
open: boolean,
|
||||
workspaceId: string | null,
|
||||
repositories: Repository[],
|
||||
) {
|
||||
const { repositoryId, selectedLocalRepo, useGitHubUrl, setCurrentLocalBranch } = fs;
|
||||
useEffect(() => {
|
||||
if (!open || !workspaceId || useGitHubUrl) {
|
||||
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;
|
||||
}
|
||||
if (!path) {
|
||||
setCurrentLocalBranch("");
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
getLocalRepositoryStatusAction(workspaceId, path)
|
||||
.then((r) => {
|
||||
if (!cancelled) setCurrentLocalBranch(r.current_branch ?? "");
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setCurrentLocalBranch("");
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [
|
||||
open,
|
||||
workspaceId,
|
||||
useGitHubUrl,
|
||||
repositoryId,
|
||||
selectedLocalRepo,
|
||||
repositories,
|
||||
setCurrentLocalBranch,
|
||||
]);
|
||||
}
|
||||
|
||||
export function useDefaultSelectionsEffect(
|
||||
@@ -413,6 +476,7 @@ export function useTaskCreateDialogEffects(fs: DialogFormState, args: TaskCreate
|
||||
useDiscoverReposEffect(fs, open, workspaceId, repositoriesLoading, toast);
|
||||
useBranchAutoSelectEffect(fs, branches);
|
||||
useLocalBranchesEffect(fs, open, workspaceId, toast);
|
||||
useCurrentLocalBranchEffect(fs, open, workspaceId, repositories);
|
||||
useDefaultSelectionsEffect(fs, open, { agentProfiles, executors, workspaceDefaults }, workflows);
|
||||
useGitHubUrlBranchesEffect(fs, open);
|
||||
}
|
||||
|
||||
@@ -9,6 +9,10 @@ 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";
|
||||
|
||||
type SelectorOption = {
|
||||
@@ -67,94 +71,124 @@ type CreateEditSelectorsProps = {
|
||||
isLocalExecutor: boolean;
|
||||
useGitHubUrl: boolean;
|
||||
workflowAgentLocked: boolean;
|
||||
freshBranchEnabled: boolean;
|
||||
onToggleFreshBranch: (enabled: boolean) => void;
|
||||
currentLocalBranch: string;
|
||||
};
|
||||
|
||||
export const CreateEditSelectors = memo(function CreateEditSelectors({
|
||||
isTaskStarted,
|
||||
hasRepositorySelection,
|
||||
branchOptions,
|
||||
branch,
|
||||
onBranchChange,
|
||||
branchesLoading,
|
||||
localBranchesLoading,
|
||||
type AgentColumnProps = Pick<
|
||||
CreateEditSelectorsProps,
|
||||
| "agentProfiles"
|
||||
| "agentProfilesLoading"
|
||||
| "agentProfileOptions"
|
||||
| "agentProfileId"
|
||||
| "onAgentProfileChange"
|
||||
| "isCreatingSession"
|
||||
| "AgentSelectorComponent"
|
||||
| "workflowAgentLocked"
|
||||
>;
|
||||
|
||||
function AgentColumn({
|
||||
agentProfiles,
|
||||
agentProfilesLoading,
|
||||
agentProfileOptions,
|
||||
agentProfileId,
|
||||
onAgentProfileChange,
|
||||
isCreatingSession,
|
||||
executorProfileOptions,
|
||||
executorProfileId,
|
||||
onExecutorProfileChange,
|
||||
executorsLoading,
|
||||
isLocalExecutor,
|
||||
useGitHubUrl,
|
||||
workflowAgentLocked,
|
||||
BranchSelectorComponent,
|
||||
AgentSelectorComponent,
|
||||
ExecutorProfileSelectorComponent,
|
||||
}: CreateEditSelectorsProps) {
|
||||
if (isTaskStarted) return null;
|
||||
workflowAgentLocked,
|
||||
}: AgentColumnProps) {
|
||||
if (agentProfiles.length === 0 && !agentProfilesLoading) {
|
||||
return (
|
||||
<div className="flex h-7 items-center justify-center gap-2 rounded-sm border border-input px-3 text-xs text-muted-foreground">
|
||||
<span>No agents found.</span>
|
||||
<Link href="/settings/agents" className="cursor-pointer text-primary hover:underline">
|
||||
Add agent
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const placeholder = agentProfilesLoading ? "Loading agents..." : "Select agent";
|
||||
return (
|
||||
<>
|
||||
<AgentSelectorComponent
|
||||
options={agentProfileOptions}
|
||||
value={agentProfileId}
|
||||
onValueChange={onAgentProfileChange}
|
||||
placeholder={placeholder}
|
||||
disabled={agentProfilesLoading || isCreatingSession || workflowAgentLocked}
|
||||
/>
|
||||
{workflowAgentLocked && (
|
||||
<p className="text-[11px] text-muted-foreground mt-1">Agent set by workflow</p>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const agentLockedByWorkflow = workflowAgentLocked;
|
||||
export const CreateEditSelectors = memo(function CreateEditSelectors(
|
||||
props: CreateEditSelectorsProps,
|
||||
) {
|
||||
if (props.isTaskStarted) return null;
|
||||
|
||||
const {
|
||||
hasRepositorySelection,
|
||||
branchOptions,
|
||||
branch,
|
||||
onBranchChange,
|
||||
branchesLoading,
|
||||
localBranchesLoading,
|
||||
executorProfileOptions,
|
||||
executorProfileId,
|
||||
onExecutorProfileChange,
|
||||
executorsLoading,
|
||||
isLocalExecutor,
|
||||
useGitHubUrl,
|
||||
freshBranchEnabled,
|
||||
onToggleFreshBranch,
|
||||
currentLocalBranch,
|
||||
BranchSelectorComponent,
|
||||
ExecutorProfileSelectorComponent,
|
||||
} = props;
|
||||
const isLocalWithoutGitHubUrl = isLocalExecutor && !useGitHubUrl;
|
||||
|
||||
const branchPlaceholder = (() => {
|
||||
if (isLocalWithoutGitHubUrl) return "Uses current branch";
|
||||
if (!hasRepositorySelection) return "Select repository first";
|
||||
if (branchesLoading || localBranchesLoading) return "Loading branches...";
|
||||
return branchOptions.length > 0 ? "Select branch" : "No branches found";
|
||||
})();
|
||||
|
||||
const lockedToCurrentBranch = isLocalWithoutGitHubUrl && !freshBranchEnabled;
|
||||
const branchPlaceholder = computeBranchPlaceholder({
|
||||
lockedToCurrentBranch,
|
||||
currentLocalBranch,
|
||||
hasRepositorySelection,
|
||||
loading: branchesLoading || localBranchesLoading,
|
||||
optionCount: branchOptions.length,
|
||||
});
|
||||
const branchDisabled =
|
||||
isLocalWithoutGitHubUrl ||
|
||||
lockedToCurrentBranch ||
|
||||
!hasRepositorySelection ||
|
||||
branchesLoading ||
|
||||
localBranchesLoading ||
|
||||
branchOptions.length === 0;
|
||||
|
||||
const agentPlaceholder = (() => {
|
||||
if (agentProfilesLoading) return "Loading agents...";
|
||||
if (agentProfiles.length === 0) return "No agents available";
|
||||
return "Select agent";
|
||||
})();
|
||||
// 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;
|
||||
|
||||
return (
|
||||
<div className="grid gap-4 grid-cols-1 sm:grid-cols-3">
|
||||
<div>
|
||||
<BranchSelectorComponent
|
||||
options={branchOptions}
|
||||
value={branch}
|
||||
onValueChange={onBranchChange}
|
||||
placeholder={branchPlaceholder}
|
||||
searchPlaceholder="Search branches..."
|
||||
emptyMessage="No branch found."
|
||||
disabled={branchDisabled}
|
||||
/>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<div className="min-w-0 flex-1">
|
||||
<BranchSelectorComponent
|
||||
options={branchOptions}
|
||||
value={branchValue}
|
||||
onValueChange={onBranchChange}
|
||||
placeholder={branchPlaceholder}
|
||||
searchPlaceholder="Search branches..."
|
||||
emptyMessage="No branch found."
|
||||
disabled={branchDisabled}
|
||||
/>
|
||||
</div>
|
||||
{isLocalWithoutGitHubUrl && (
|
||||
<FreshBranchToggle enabled={freshBranchEnabled} onToggle={onToggleFreshBranch} />
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
{agentProfiles.length === 0 && !agentProfilesLoading ? (
|
||||
<div className="flex h-7 items-center justify-center gap-2 rounded-sm border border-input px-3 text-xs text-muted-foreground">
|
||||
<span>No agents found.</span>
|
||||
<Link href="/settings/agents" className="text-primary hover:underline">
|
||||
Add agent
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<AgentSelectorComponent
|
||||
options={agentProfileOptions}
|
||||
value={agentProfileId}
|
||||
onValueChange={onAgentProfileChange}
|
||||
placeholder={agentPlaceholder}
|
||||
disabled={agentProfilesLoading || isCreatingSession || agentLockedByWorkflow}
|
||||
/>
|
||||
{agentLockedByWorkflow && (
|
||||
<p className="text-[11px] text-muted-foreground mt-1">Agent set by workflow</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<AgentColumn {...props} />
|
||||
</div>
|
||||
<div>
|
||||
<ExecutorProfileSelectorComponent
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useState } from "react";
|
||||
import { getLocalRepositoryStatusAction } from "@/app/actions/workspaces";
|
||||
import { ApiError } from "@/lib/api/client";
|
||||
import { createTask } from "@/lib/api";
|
||||
import type { useToast } from "@/components/toast-provider";
|
||||
|
||||
type CreateTaskParams = Parameters<typeof createTask>[0];
|
||||
type CreateTaskResponse = Awaited<ReturnType<typeof createTask>>;
|
||||
|
||||
export function dirtyFilesFromApiError(err: unknown): string[] | null {
|
||||
if (!(err instanceof ApiError) || err.status !== 409) return null;
|
||||
const body = err.body as { dirty_files?: unknown } | null;
|
||||
if (!body || !Array.isArray(body.dirty_files)) return null;
|
||||
// Backend should never send an empty list with 409, but guard anyway so a
|
||||
// malformed response doesn't open the consent dialog with no files in it.
|
||||
const files = body.dirty_files.filter((p): p is string => typeof p === "string");
|
||||
return files.length > 0 ? files : null;
|
||||
}
|
||||
|
||||
export type PendingDiscard = {
|
||||
dirtyFiles: string[];
|
||||
repoPath: string;
|
||||
resolve: (confirmed: boolean) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Result of the consent check:
|
||||
* - `null` when the user cancelled (caller should abort)
|
||||
* - empty array when fresh-branch is inactive or the working tree is clean
|
||||
* (proceed without consent)
|
||||
* - non-empty array of dirty paths the user explicitly OK'd discarding
|
||||
*/
|
||||
export type ConsentResult = string[] | null;
|
||||
|
||||
type Args = {
|
||||
isFreshBranchActive: boolean;
|
||||
workspaceId: string | null;
|
||||
repositoryLocalPath: string;
|
||||
toast: ReturnType<typeof useToast>["toast"];
|
||||
};
|
||||
|
||||
/**
|
||||
* Coordinates the destructive-checkout consent modal for the fresh-branch flow.
|
||||
*
|
||||
* `ensureFreshBranchConsent()` runs the preflight via `getLocalRepositoryStatusAction`
|
||||
* and prompts only when the working tree is dirty. `confirmForDirtyFiles(list)`
|
||||
* skips the preflight and reuses a list provided by the caller — used to
|
||||
* recover when the backend rejects with 409 because new files became dirty
|
||||
* between preflight and submit.
|
||||
*/
|
||||
export function useFreshBranchConsent({
|
||||
isFreshBranchActive,
|
||||
workspaceId,
|
||||
repositoryLocalPath,
|
||||
toast,
|
||||
}: Args) {
|
||||
const [pendingDiscard, setPendingDiscard] = useState<PendingDiscard | null>(null);
|
||||
|
||||
const promptForList = useCallback(
|
||||
(dirtyFiles: string[]): Promise<ConsentResult> =>
|
||||
new Promise<ConsentResult>((resolve) => {
|
||||
setPendingDiscard({
|
||||
dirtyFiles,
|
||||
repoPath: repositoryLocalPath,
|
||||
resolve: (confirmed) => {
|
||||
setPendingDiscard(null);
|
||||
resolve(confirmed ? dirtyFiles : null);
|
||||
},
|
||||
});
|
||||
}),
|
||||
[repositoryLocalPath],
|
||||
);
|
||||
|
||||
const ensureFreshBranchConsent = useCallback(async (): Promise<ConsentResult> => {
|
||||
if (!isFreshBranchActive || !workspaceId) return [];
|
||||
try {
|
||||
const status = await getLocalRepositoryStatusAction(workspaceId, repositoryLocalPath);
|
||||
if (status.dirty_files.length === 0) return [];
|
||||
return await promptForList(status.dirty_files);
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: "Failed to check local repository status",
|
||||
description: error instanceof Error ? error.message : "Request failed",
|
||||
variant: "error",
|
||||
});
|
||||
return null;
|
||||
}
|
||||
}, [isFreshBranchActive, workspaceId, repositoryLocalPath, promptForList, toast]);
|
||||
|
||||
/**
|
||||
* Calls createTask once, then re-prompts the user with the updated dirty
|
||||
* list and retries once if the backend returned 409 because the working
|
||||
* tree gained new dirty files between the consent dialog and the actual
|
||||
* discard. Returns null if the user cancelled the second prompt.
|
||||
*/
|
||||
const createTaskWithFreshBranchRetry = useCallback(
|
||||
async (
|
||||
build: (consented: string[]) => CreateTaskParams,
|
||||
initial: string[],
|
||||
): Promise<CreateTaskResponse | null> => {
|
||||
try {
|
||||
return await createTask(build(initial));
|
||||
} catch (err) {
|
||||
if (!isFreshBranchActive) throw err;
|
||||
const dirty = dirtyFilesFromApiError(err);
|
||||
if (!dirty) throw err;
|
||||
const reconsented = await promptForList(dirty);
|
||||
if (reconsented === null) return null;
|
||||
return await createTask(build(reconsented));
|
||||
}
|
||||
},
|
||||
[isFreshBranchActive, promptForList],
|
||||
);
|
||||
|
||||
return {
|
||||
pendingDiscard,
|
||||
ensureFreshBranchConsent,
|
||||
confirmForDirtyFiles: promptForList,
|
||||
createTaskWithFreshBranchRetry,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
"use client";
|
||||
|
||||
import { IconGitFork } from "@tabler/icons-react";
|
||||
import { Toggle } from "@kandev/ui/toggle";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
|
||||
|
||||
const FRESH_BRANCH_TOOLTIP =
|
||||
"Create a new branch from the selected base. Any uncommitted changes in your local clone will be discarded; you'll be asked to confirm if there are any.";
|
||||
|
||||
export type FreshBranchToggleProps = {
|
||||
enabled: boolean;
|
||||
onToggle: (enabled: boolean) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Compact icon toggle shown beside the branch selector for local executors.
|
||||
* Pressed = "fork a new branch from the chosen base on submit". Matches the
|
||||
* affordance pattern used by other inline toggles in the dialog (e.g. the
|
||||
* paperclip in the prompt input).
|
||||
*/
|
||||
export function FreshBranchToggle({ enabled, onToggle }: FreshBranchToggleProps) {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Toggle
|
||||
variant="outline"
|
||||
aria-label="Create a new branch"
|
||||
pressed={enabled}
|
||||
onPressedChange={onToggle}
|
||||
data-testid="fresh-branch-toggle"
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<IconGitFork />
|
||||
</Toggle>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-xs">{FRESH_BRANCH_TOOLTIP}</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
export type BranchPlaceholderArgs = {
|
||||
lockedToCurrentBranch: boolean;
|
||||
currentLocalBranch: string;
|
||||
hasRepositorySelection: boolean;
|
||||
loading: boolean;
|
||||
optionCount: number;
|
||||
};
|
||||
|
||||
export function computeBranchPlaceholder({
|
||||
lockedToCurrentBranch,
|
||||
currentLocalBranch,
|
||||
hasRepositorySelection,
|
||||
loading,
|
||||
optionCount,
|
||||
}: BranchPlaceholderArgs) {
|
||||
if (lockedToCurrentBranch) return currentLocalBranch || "Uses current branch";
|
||||
if (!hasRepositorySelection) return "Select repository first";
|
||||
if (loading) return "Loading branches...";
|
||||
return optionCount > 0 ? "Select branch" : "No branches found";
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback } from "react";
|
||||
import type { Repository } from "@/lib/types/http";
|
||||
import { setLocalStorage } from "@/lib/local-storage";
|
||||
import { STORAGE_KEYS } from "@/lib/settings/constants";
|
||||
import type { DialogFormState } from "@/components/task-create-dialog-types";
|
||||
|
||||
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([]);
|
||||
clearFreshBranch(fs);
|
||||
},
|
||||
[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 };
|
||||
}
|
||||
|
||||
function useBranchAndProfileHandlers(fs: DialogFormState) {
|
||||
const handleAgentProfileChange = useCallback(
|
||||
(value: string) => {
|
||||
fs.setAgentProfileId(value);
|
||||
setLocalStorage(STORAGE_KEYS.LAST_AGENT_PROFILE_ID, value);
|
||||
},
|
||||
[fs],
|
||||
);
|
||||
const handleExecutorProfileChange = useCallback(
|
||||
(value: string) => {
|
||||
fs.setExecutorProfileId(value);
|
||||
setLocalStorage(STORAGE_KEYS.LAST_EXECUTOR_PROFILE_ID, value);
|
||||
},
|
||||
[fs],
|
||||
);
|
||||
const handleTaskNameChange = useCallback(
|
||||
(value: string) => {
|
||||
fs.setTaskName(value);
|
||||
fs.setHasTitle(value.trim().length > 0);
|
||||
},
|
||||
[fs],
|
||||
);
|
||||
const handleBranchChange = useCallback(
|
||||
(value: string) => {
|
||||
fs.setBranch(value);
|
||||
setLocalStorage(STORAGE_KEYS.LAST_BRANCH, value);
|
||||
},
|
||||
[fs],
|
||||
);
|
||||
const handleWorkflowChange = useCallback(
|
||||
(value: string) => fs.setSelectedWorkflowId(value),
|
||||
[fs],
|
||||
);
|
||||
return {
|
||||
handleAgentProfileChange,
|
||||
handleExecutorProfileChange,
|
||||
handleTaskNameChange,
|
||||
handleBranchChange,
|
||||
handleWorkflowChange,
|
||||
};
|
||||
}
|
||||
|
||||
function useGitHubAndFreshBranchHandlers(fs: DialogFormState) {
|
||||
const handleToggleGitHubUrl = useCallback(() => {
|
||||
const next = !fs.useGitHubUrl;
|
||||
fs.setUseGitHubUrl(next);
|
||||
if (next) {
|
||||
fs.setRepositoryId("");
|
||||
fs.setDiscoveredRepoPath("");
|
||||
fs.setSelectedLocalRepo(null);
|
||||
fs.setLocalBranches([]);
|
||||
} else {
|
||||
fs.setGitHubUrl("");
|
||||
fs.setGitHubBranches([]);
|
||||
fs.setGitHubUrlError(null);
|
||||
fs.setGitHubPrHeadBranch(null);
|
||||
}
|
||||
fs.setBranch("");
|
||||
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("");
|
||||
},
|
||||
[fs],
|
||||
);
|
||||
|
||||
const handleGitHubUrlChange = useCallback(
|
||||
(value: string) => {
|
||||
fs.setGitHubUrl(value);
|
||||
fs.setBranch("");
|
||||
fs.setGitHubBranches([]);
|
||||
fs.setGitHubUrlError(null);
|
||||
fs.setGitHubPrHeadBranch(null);
|
||||
},
|
||||
[fs],
|
||||
);
|
||||
|
||||
return {
|
||||
handleToggleGitHubUrl,
|
||||
handleToggleFreshBranch,
|
||||
handleGitHubUrlChange,
|
||||
};
|
||||
}
|
||||
|
||||
export function useDialogHandlers(fs: DialogFormState, repositories: Repository[]) {
|
||||
const repo = useRepositoryHandlers(fs, repositories);
|
||||
const profile = useBranchAndProfileHandlers(fs);
|
||||
const gh = useGitHubAndFreshBranchHandlers(fs);
|
||||
return {
|
||||
handleRepositoryChange: repo.handleRepositoryChange,
|
||||
...profile,
|
||||
...gh,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
"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<typeof useRepositoryOptions>["headerRepositoryOptions"];
|
||||
onRepositoryChange: (v: string) => void;
|
||||
onTaskNameChange: (v: string) => void;
|
||||
useGitHubUrl: boolean;
|
||||
githubUrl: string;
|
||||
githubUrlError: string | null;
|
||||
onToggleGitHubUrl: () => void;
|
||||
onGitHubUrlChange: (v: string) => void;
|
||||
};
|
||||
|
||||
function RepoSourceInput({
|
||||
useGitHubUrl,
|
||||
githubUrl,
|
||||
githubUrlError,
|
||||
onGitHubUrlChange,
|
||||
isTaskStarted,
|
||||
headerRepositoryOptions,
|
||||
repositoryId,
|
||||
discoveredRepoPath,
|
||||
onRepositoryChange,
|
||||
workspaceId,
|
||||
repositoriesLoading,
|
||||
discoverReposLoading,
|
||||
}: Pick<
|
||||
DialogHeaderContentProps,
|
||||
| "useGitHubUrl"
|
||||
| "githubUrl"
|
||||
| "githubUrlError"
|
||||
| "onGitHubUrlChange"
|
||||
| "isTaskStarted"
|
||||
| "headerRepositoryOptions"
|
||||
| "repositoryId"
|
||||
| "discoveredRepoPath"
|
||||
| "onRepositoryChange"
|
||||
| "workspaceId"
|
||||
| "repositoriesLoading"
|
||||
| "discoverReposLoading"
|
||||
>): React.ReactNode {
|
||||
if (useGitHubUrl) {
|
||||
return (
|
||||
<div className="relative">
|
||||
<input
|
||||
type="text"
|
||||
value={githubUrl}
|
||||
onChange={(e) => 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 && (
|
||||
<div
|
||||
id="github-url-error"
|
||||
role="alert"
|
||||
className="absolute left-0 top-full mt-1 z-50 rounded-md border bg-popover px-2 py-1 text-[11px] text-destructive shadow-md whitespace-nowrap"
|
||||
data-testid="github-url-error"
|
||||
>
|
||||
{githubUrlError}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<RepositorySelector
|
||||
options={headerRepositoryOptions}
|
||||
value={repositoryId || discoveredRepoPath}
|
||||
onValueChange={onRepositoryChange}
|
||||
placeholder={getRepositoryPlaceholder(workspaceId, repositoriesLoading, discoverReposLoading)}
|
||||
searchPlaceholder="Search repositories..."
|
||||
emptyMessage={
|
||||
repositoriesLoading || discoverReposLoading
|
||||
? "Loading repositories..."
|
||||
: "No repositories found."
|
||||
}
|
||||
disabled={isTaskStarted || !workspaceId || repositoriesLoading || discoverReposLoading}
|
||||
triggerClassName="w-auto text-sm"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function DialogHeaderContent(props: DialogHeaderContentProps) {
|
||||
const { isCreateMode, isEditMode, isTaskStarted, sessionRepoName, initialTitle } = props;
|
||||
const { taskName, onTaskNameChange, useGitHubUrl, onToggleGitHubUrl } = props;
|
||||
|
||||
if (isCreateMode || isEditMode) {
|
||||
return (
|
||||
<>
|
||||
<DialogTitle asChild>
|
||||
<div className="flex items-center gap-1 text-sm font-medium min-w-0">
|
||||
<RepoSourceInput {...props} />
|
||||
<span className="text-muted-foreground mr-2">/</span>
|
||||
<InlineTaskName
|
||||
value={taskName}
|
||||
onChange={onTaskNameChange}
|
||||
autoFocus={!isEditMode && !useGitHubUrl}
|
||||
/>
|
||||
</div>
|
||||
</DialogTitle>
|
||||
{!isTaskStarted && (
|
||||
<div className="flex items-center gap-2 pl-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleGitHubUrl}
|
||||
className="text-xs text-muted-foreground hover:text-foreground cursor-pointer transition-colors"
|
||||
data-testid="toggle-github-url"
|
||||
>
|
||||
{useGitHubUrl ? "or select a repository" : "or paste a GitHub URL"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<DialogTitle asChild>
|
||||
<div className="flex items-center gap-1 min-w-0 text-sm font-medium">
|
||||
{sessionRepoName && (
|
||||
<>
|
||||
<span className="truncate text-muted-foreground">{sessionRepoName}</span>
|
||||
<span className="text-muted-foreground mx-0.5">/</span>
|
||||
</>
|
||||
)}
|
||||
<span className="truncate">{initialTitle || "Task"}</span>
|
||||
<span className="text-muted-foreground mx-0.5">/</span>
|
||||
<span className="text-muted-foreground whitespace-nowrap">new session</span>
|
||||
</div>
|
||||
</DialogTitle>
|
||||
);
|
||||
}
|
||||
@@ -160,6 +160,7 @@ export function buildRepositoriesPayload(opts: {
|
||||
githubPrHeadBranch: string | null;
|
||||
repositoryId: string;
|
||||
selectedLocalRepo: LocalRepository | null;
|
||||
freshBranch?: { confirmDiscard: boolean; consentedDirtyFiles: string[] };
|
||||
}): NonNullable<CreateTaskParams["repositories"]> {
|
||||
if (opts.useGitHubUrl && opts.githubUrl) {
|
||||
return [
|
||||
@@ -171,12 +172,20 @@ export function buildRepositoriesPayload(opts: {
|
||||
},
|
||||
];
|
||||
}
|
||||
const fresh = opts.freshBranch
|
||||
? {
|
||||
fresh_branch: true,
|
||||
confirm_discard: opts.freshBranch.confirmDiscard,
|
||||
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,
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -187,6 +196,7 @@ export function buildRepositoriesPayload(opts: {
|
||||
base_branch: opts.branch || undefined,
|
||||
local_path: opts.selectedLocalRepo.path,
|
||||
default_branch: opts.selectedLocalRepo.default_branch || undefined,
|
||||
...fresh,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@@ -1,13 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState, useMemo, useCallback } from "react";
|
||||
import type {
|
||||
LocalRepository,
|
||||
Workspace,
|
||||
Repository,
|
||||
ExecutorProfile,
|
||||
Branch,
|
||||
} from "@/lib/types/http";
|
||||
import type { LocalRepository, Workspace, ExecutorProfile, Branch } from "@/lib/types/http";
|
||||
import type { TaskFormInputsHandle } from "@/components/task-create-dialog-types";
|
||||
import { useAppStore } from "@/components/state-provider";
|
||||
import { useRepositories } from "@/hooks/domains/workspace/use-repositories";
|
||||
@@ -21,13 +15,7 @@ import {
|
||||
useExecutorProfileOptions,
|
||||
useIsLocalExecutor,
|
||||
} from "@/components/task-create-dialog-options";
|
||||
import {
|
||||
setLocalStorage,
|
||||
getTaskCreateDraft,
|
||||
setTaskCreateDraft,
|
||||
removeTaskCreateDraft,
|
||||
} from "@/lib/local-storage";
|
||||
import { STORAGE_KEYS } from "@/lib/settings/constants";
|
||||
import { getTaskCreateDraft, setTaskCreateDraft, removeTaskCreateDraft } from "@/lib/local-storage";
|
||||
import type {
|
||||
StepType,
|
||||
TaskCreateDialogInitialValues,
|
||||
@@ -67,6 +55,8 @@ type FormResetters = {
|
||||
setGitHubBranches: (v: Branch[]) => void;
|
||||
setGitHubUrlError: (v: string | null) => void;
|
||||
setGitHubPrHeadBranch: (v: string | null) => void;
|
||||
setFreshBranchEnabled: (v: boolean) => void;
|
||||
setCurrentLocalBranch: (v: string) => void;
|
||||
};
|
||||
|
||||
type FormResetEffectsArgs = {
|
||||
@@ -173,6 +163,8 @@ function resetDiscoveryState(resetters: FormResetters, iv?: TaskCreateDialogInit
|
||||
resetters.setGitHubBranches([]);
|
||||
resetters.setGitHubUrlError(null);
|
||||
resetters.setGitHubPrHeadBranch(iv?.checkoutBranch ?? null);
|
||||
resetters.setFreshBranchEnabled(false);
|
||||
resetters.setCurrentLocalBranch("");
|
||||
}
|
||||
|
||||
/** Hook to manage draft persistence for task creation dialog */
|
||||
@@ -223,6 +215,17 @@ function useWorkflowAgentProfileState() {
|
||||
return { workflowAgentProfileId, setWorkflowAgentProfileId };
|
||||
}
|
||||
|
||||
function useFreshBranchState() {
|
||||
const [freshBranchEnabled, setFreshBranchEnabled] = useState(false);
|
||||
const [currentLocalBranch, setCurrentLocalBranch] = useState("");
|
||||
return {
|
||||
freshBranchEnabled,
|
||||
setFreshBranchEnabled,
|
||||
currentLocalBranch,
|
||||
setCurrentLocalBranch,
|
||||
};
|
||||
}
|
||||
|
||||
function useGitHubUrlState() {
|
||||
const [useGitHubUrl, setUseGitHubUrl] = useState(false);
|
||||
const [githubUrl, setGitHubUrl] = useState("");
|
||||
@@ -354,6 +357,7 @@ export function useDialogFormState(
|
||||
const discovery = useDiscoveryState();
|
||||
const ghUrl = useGitHubUrlState();
|
||||
const wfAgent = useWorkflowAgentProfileState();
|
||||
const freshBranch = useFreshBranchState();
|
||||
|
||||
useFormResetEffects({
|
||||
open,
|
||||
@@ -385,6 +389,8 @@ export function useDialogFormState(
|
||||
setGitHubBranches: ghUrl.setGitHubBranches,
|
||||
setGitHubUrlError: ghUrl.setGitHubUrlError,
|
||||
setGitHubPrHeadBranch: ghUrl.setGitHubPrHeadBranch,
|
||||
setFreshBranchEnabled: freshBranch.setFreshBranchEnabled,
|
||||
setCurrentLocalBranch: freshBranch.setCurrentLocalBranch,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -396,7 +402,7 @@ export function useDialogFormState(
|
||||
form.descriptionInputRef,
|
||||
);
|
||||
|
||||
return { ...form, ...discovery, ...ghUrl, ...wfAgent, clearDraft };
|
||||
return { ...form, ...discovery, ...ghUrl, ...wfAgent, ...freshBranch, clearDraft };
|
||||
}
|
||||
|
||||
export type { DialogFormState } from "@/components/task-create-dialog-types";
|
||||
@@ -407,111 +413,7 @@ export {
|
||||
} from "@/components/task-create-dialog-helpers";
|
||||
export { useTaskCreateDialogEffects } from "@/components/task-create-dialog-effects";
|
||||
|
||||
export function useDialogHandlers(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([]);
|
||||
},
|
||||
[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([]);
|
||||
return;
|
||||
}
|
||||
handleSelectLocalRepository(value);
|
||||
},
|
||||
[repositories, fs, handleSelectLocalRepository],
|
||||
);
|
||||
|
||||
const handleAgentProfileChange = useCallback(
|
||||
(value: string) => {
|
||||
fs.setAgentProfileId(value);
|
||||
setLocalStorage(STORAGE_KEYS.LAST_AGENT_PROFILE_ID, value);
|
||||
},
|
||||
[fs],
|
||||
);
|
||||
const handleExecutorProfileChange = useCallback(
|
||||
(value: string) => {
|
||||
fs.setExecutorProfileId(value);
|
||||
setLocalStorage(STORAGE_KEYS.LAST_EXECUTOR_PROFILE_ID, value);
|
||||
},
|
||||
[fs],
|
||||
);
|
||||
const handleTaskNameChange = useCallback(
|
||||
(value: string) => {
|
||||
fs.setTaskName(value);
|
||||
fs.setHasTitle(value.trim().length > 0);
|
||||
},
|
||||
[fs],
|
||||
);
|
||||
const handleBranchChange = useCallback(
|
||||
(value: string) => {
|
||||
fs.setBranch(value);
|
||||
setLocalStorage(STORAGE_KEYS.LAST_BRANCH, value);
|
||||
},
|
||||
[fs],
|
||||
);
|
||||
const handleWorkflowChange = useCallback(
|
||||
(value: string) => {
|
||||
fs.setSelectedWorkflowId(value);
|
||||
},
|
||||
[fs],
|
||||
);
|
||||
|
||||
const handleToggleGitHubUrl = useCallback(() => {
|
||||
const next = !fs.useGitHubUrl;
|
||||
fs.setUseGitHubUrl(next);
|
||||
if (next) {
|
||||
fs.setRepositoryId("");
|
||||
fs.setDiscoveredRepoPath("");
|
||||
fs.setSelectedLocalRepo(null);
|
||||
fs.setLocalBranches([]);
|
||||
} else {
|
||||
fs.setGitHubUrl("");
|
||||
fs.setGitHubBranches([]);
|
||||
fs.setGitHubUrlError(null);
|
||||
fs.setGitHubPrHeadBranch(null);
|
||||
}
|
||||
fs.setBranch("");
|
||||
}, [fs]);
|
||||
|
||||
const handleGitHubUrlChange = useCallback(
|
||||
(value: string) => {
|
||||
fs.setGitHubUrl(value);
|
||||
fs.setBranch("");
|
||||
fs.setGitHubBranches([]);
|
||||
fs.setGitHubUrlError(null);
|
||||
fs.setGitHubPrHeadBranch(null);
|
||||
},
|
||||
[fs],
|
||||
);
|
||||
|
||||
return {
|
||||
handleRepositoryChange,
|
||||
handleAgentProfileChange,
|
||||
handleExecutorProfileChange,
|
||||
handleTaskNameChange,
|
||||
handleBranchChange,
|
||||
handleWorkflowChange,
|
||||
handleToggleGitHubUrl,
|
||||
handleGitHubUrlChange,
|
||||
};
|
||||
}
|
||||
export { useDialogHandlers } from "@/components/task-create-dialog-handlers";
|
||||
|
||||
export function useDialogComputed({
|
||||
fs,
|
||||
|
||||
@@ -2,13 +2,14 @@
|
||||
|
||||
import { useCallback, FormEvent } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { createTask, updateTask } from "@/lib/api";
|
||||
import { updateTask } from "@/lib/api";
|
||||
import { useAppStore } from "@/components/state-provider";
|
||||
import { launchSession } from "@/lib/services/session-launch-service";
|
||||
import { buildStartRequest } from "@/lib/services/session-launch-helpers";
|
||||
import { useToast } from "@/components/toast-provider";
|
||||
import { linkToTask } from "@/lib/links";
|
||||
import type { SubmitHandlersDeps } from "@/components/task-create-dialog-types";
|
||||
import { useFreshBranchConsent } from "@/components/task-create-dialog-fresh-branch-consent";
|
||||
|
||||
import {
|
||||
activatePlanMode,
|
||||
@@ -16,7 +17,6 @@ import {
|
||||
buildRepositoriesPayload,
|
||||
validateCreateInputs,
|
||||
toMessageAttachments,
|
||||
type CreateTaskParams,
|
||||
} from "@/components/task-create-dialog-helpers";
|
||||
|
||||
const GENERIC_ERROR_MESSAGE = "An error occurred";
|
||||
@@ -59,12 +59,42 @@ export function useTaskSubmitHandlers({
|
||||
setSelectedWorkflowId,
|
||||
setFetchedSteps,
|
||||
clearDraft,
|
||||
freshBranchEnabled,
|
||||
isLocalExecutor,
|
||||
repositoryLocalPath,
|
||||
}: SubmitHandlersDeps) {
|
||||
const router = useRouter();
|
||||
const { toast } = useToast();
|
||||
const setActiveDocument = useAppStore((state) => state.setActiveDocument);
|
||||
const setPlanMode = useAppStore((state) => state.setPlanMode);
|
||||
|
||||
const isFreshBranchActive =
|
||||
freshBranchEnabled && isLocalExecutor && !useGitHubUrl && repositoryLocalPath !== "";
|
||||
const { pendingDiscard, ensureFreshBranchConsent, createTaskWithFreshBranchRetry } =
|
||||
useFreshBranchConsent({
|
||||
isFreshBranchActive,
|
||||
workspaceId,
|
||||
repositoryLocalPath,
|
||||
toast,
|
||||
});
|
||||
|
||||
const buildFreshBranchPayload = (consentedDirtyFiles: string[]) =>
|
||||
isFreshBranchActive ? { confirmDiscard: true, consentedDirtyFiles } : undefined;
|
||||
|
||||
const validateForCreate = useCallback(
|
||||
(trimmedTitle: string) =>
|
||||
validateCreateInputs({
|
||||
trimmedTitle,
|
||||
workspaceId,
|
||||
effectiveWorkflowId,
|
||||
repositoryId,
|
||||
selectedLocalRepo,
|
||||
githubUrl,
|
||||
agentProfileId,
|
||||
}),
|
||||
[workspaceId, effectiveWorkflowId, repositoryId, selectedLocalRepo, githubUrl, agentProfileId],
|
||||
);
|
||||
|
||||
const resetForm = useCallback(() => {
|
||||
setHasTitle(false);
|
||||
setHasDescription(false);
|
||||
@@ -90,7 +120,7 @@ export function useTaskSubmitHandlers({
|
||||
]);
|
||||
|
||||
const getRepositoriesPayload = useCallback(
|
||||
() =>
|
||||
(consentedDirtyFiles: string[] = []) =>
|
||||
buildRepositoriesPayload({
|
||||
useGitHubUrl,
|
||||
githubUrl,
|
||||
@@ -98,8 +128,19 @@ export function useTaskSubmitHandlers({
|
||||
githubPrHeadBranch,
|
||||
repositoryId,
|
||||
selectedLocalRepo,
|
||||
freshBranch: buildFreshBranchPayload(consentedDirtyFiles),
|
||||
}),
|
||||
[useGitHubUrl, repositoryId, branch, githubUrl, githubPrHeadBranch, selectedLocalRepo],
|
||||
// buildFreshBranchPayload is intentionally a closure over current scope; lint exception kept narrow.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[
|
||||
useGitHubUrl,
|
||||
repositoryId,
|
||||
branch,
|
||||
githubUrl,
|
||||
githubPrHeadBranch,
|
||||
selectedLocalRepo,
|
||||
isFreshBranchActive,
|
||||
],
|
||||
);
|
||||
|
||||
const handleSessionSubmit = useCallback(async () => {
|
||||
@@ -232,36 +273,38 @@ export function useTaskSubmitHandlers({
|
||||
}
|
||||
}, [performTaskUpdate, onSuccess, onOpenChange, toast, setIsCreatingTask]);
|
||||
|
||||
const performCreateWithAgent = useCallback(
|
||||
async (
|
||||
trimmedTitle: string,
|
||||
trimmedDescription: string,
|
||||
repositoriesPayload: CreateTaskParams["repositories"],
|
||||
planMode?: boolean,
|
||||
attachments?: ReturnType<typeof toMessageAttachments>,
|
||||
) => {
|
||||
const performCreate = useCallback(
|
||||
async (opts: {
|
||||
trimmedTitle: string;
|
||||
trimmedDescription: string;
|
||||
consented: string[];
|
||||
withAgent: boolean;
|
||||
planMode?: boolean;
|
||||
attachments?: ReturnType<typeof toMessageAttachments>;
|
||||
}) => {
|
||||
if (!workspaceId || !effectiveWorkflowId) return;
|
||||
const taskResponse = await createTask(
|
||||
const buildPayload = (c: string[]) =>
|
||||
buildCreateTaskPayload({
|
||||
workspaceId,
|
||||
effectiveWorkflowId,
|
||||
trimmedTitle,
|
||||
trimmedDescription,
|
||||
repositoriesPayload,
|
||||
trimmedTitle: opts.trimmedTitle,
|
||||
trimmedDescription: opts.trimmedDescription,
|
||||
repositoriesPayload: getRepositoriesPayload(c),
|
||||
agentProfileId,
|
||||
executorId,
|
||||
executorProfileId,
|
||||
withAgent: true,
|
||||
planMode,
|
||||
attachments,
|
||||
withAgent: opts.withAgent,
|
||||
planMode: opts.planMode,
|
||||
attachments: opts.attachments,
|
||||
parentId: parentTaskId,
|
||||
}),
|
||||
);
|
||||
});
|
||||
const taskResponse = await createTaskWithFreshBranchRetry(buildPayload, opts.consented);
|
||||
if (!taskResponse) return;
|
||||
const newSessionId = taskResponse.session_id ?? taskResponse.primary_session_id ?? null;
|
||||
onSuccess?.(taskResponse, "create", { taskSessionId: newSessionId });
|
||||
clearDraft();
|
||||
onOpenChange(false);
|
||||
if (planMode && newSessionId) {
|
||||
if (opts.planMode && newSessionId) {
|
||||
activatePlanMode({
|
||||
sessionId: newSessionId,
|
||||
taskId: taskResponse.id,
|
||||
@@ -269,7 +312,7 @@ export function useTaskSubmitHandlers({
|
||||
setPlanMode,
|
||||
router,
|
||||
});
|
||||
} else if (isPassthroughProfile) {
|
||||
} else if (opts.withAgent && isPassthroughProfile) {
|
||||
router.push(linkToTask(taskResponse.id));
|
||||
}
|
||||
},
|
||||
@@ -287,55 +330,21 @@ export function useTaskSubmitHandlers({
|
||||
setActiveDocument,
|
||||
setPlanMode,
|
||||
router,
|
||||
getRepositoriesPayload,
|
||||
createTaskWithFreshBranchRetry,
|
||||
],
|
||||
);
|
||||
|
||||
const handleCreatePlanMode = useCallback(
|
||||
async (trimmedTitle: string, repositoriesPayload: CreateTaskParams["repositories"]) => {
|
||||
if (!workspaceId || !effectiveWorkflowId) return;
|
||||
const taskResponse = await createTask(
|
||||
buildCreateTaskPayload({
|
||||
workspaceId,
|
||||
effectiveWorkflowId,
|
||||
trimmedTitle,
|
||||
trimmedDescription: "",
|
||||
repositoriesPayload,
|
||||
agentProfileId,
|
||||
executorId,
|
||||
executorProfileId,
|
||||
withAgent: false,
|
||||
planMode: true,
|
||||
parentId: parentTaskId,
|
||||
}),
|
||||
);
|
||||
const newSessionId = taskResponse.session_id ?? taskResponse.primary_session_id ?? null;
|
||||
onSuccess?.(taskResponse, "create", { taskSessionId: newSessionId });
|
||||
clearDraft();
|
||||
onOpenChange(false);
|
||||
if (newSessionId) {
|
||||
activatePlanMode({
|
||||
sessionId: newSessionId,
|
||||
taskId: taskResponse.id,
|
||||
setActiveDocument,
|
||||
setPlanMode,
|
||||
router,
|
||||
});
|
||||
}
|
||||
},
|
||||
[
|
||||
workspaceId,
|
||||
effectiveWorkflowId,
|
||||
agentProfileId,
|
||||
executorId,
|
||||
executorProfileId,
|
||||
parentTaskId,
|
||||
onSuccess,
|
||||
onOpenChange,
|
||||
clearDraft,
|
||||
setActiveDocument,
|
||||
setPlanMode,
|
||||
router,
|
||||
],
|
||||
(trimmedTitle: string, consented: string[]) =>
|
||||
performCreate({
|
||||
trimmedTitle,
|
||||
trimmedDescription: "",
|
||||
consented,
|
||||
withAgent: false,
|
||||
planMode: true,
|
||||
}),
|
||||
[performCreate],
|
||||
);
|
||||
|
||||
const performEditWithPlanMode = useCallback(async () => {
|
||||
@@ -374,37 +383,38 @@ export function useTaskSubmitHandlers({
|
||||
]);
|
||||
|
||||
const handleCreateWithPlanMode = useCallback(async () => {
|
||||
if (isEditMode) {
|
||||
setIsCreatingTask(true);
|
||||
try {
|
||||
await performEditWithPlanMode();
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: "Failed to start task in plan mode",
|
||||
description: error instanceof Error ? error.message : GENERIC_ERROR_MESSAGE,
|
||||
variant: "error",
|
||||
});
|
||||
} finally {
|
||||
setIsCreatingTask(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const trimmedTitle = taskName.trim();
|
||||
const description = descriptionInputRef.current?.getValue() ?? "";
|
||||
const trimmedDescription = description.trim();
|
||||
const attachments = toMessageAttachments(descriptionInputRef.current?.getAttachments() ?? []);
|
||||
if (!validateForCreate(trimmedTitle)) return;
|
||||
const consent = await ensureFreshBranchConsent();
|
||||
if (consent === null) return;
|
||||
setIsCreatingTask(true);
|
||||
try {
|
||||
if (isEditMode) {
|
||||
await performEditWithPlanMode();
|
||||
} else {
|
||||
const trimmedTitle = taskName.trim();
|
||||
const description = descriptionInputRef.current?.getValue() ?? "";
|
||||
const trimmedDescription = description.trim();
|
||||
const attachments = toMessageAttachments(
|
||||
descriptionInputRef.current?.getAttachments() ?? [],
|
||||
);
|
||||
if (
|
||||
!validateCreateInputs({
|
||||
trimmedTitle,
|
||||
workspaceId,
|
||||
effectiveWorkflowId,
|
||||
repositoryId,
|
||||
selectedLocalRepo,
|
||||
githubUrl,
|
||||
agentProfileId,
|
||||
})
|
||||
)
|
||||
return;
|
||||
await performCreateWithAgent(
|
||||
trimmedTitle,
|
||||
trimmedDescription,
|
||||
getRepositoriesPayload(),
|
||||
true,
|
||||
attachments,
|
||||
);
|
||||
}
|
||||
await performCreate({
|
||||
trimmedTitle,
|
||||
trimmedDescription,
|
||||
consented: consent,
|
||||
withAgent: true,
|
||||
planMode: true,
|
||||
attachments,
|
||||
});
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: "Failed to start task in plan mode",
|
||||
@@ -418,14 +428,9 @@ export function useTaskSubmitHandlers({
|
||||
isEditMode,
|
||||
performEditWithPlanMode,
|
||||
taskName,
|
||||
workspaceId,
|
||||
effectiveWorkflowId,
|
||||
repositoryId,
|
||||
selectedLocalRepo,
|
||||
githubUrl,
|
||||
agentProfileId,
|
||||
getRepositoriesPayload,
|
||||
performCreateWithAgent,
|
||||
validateForCreate,
|
||||
ensureFreshBranchConsent,
|
||||
performCreate,
|
||||
toast,
|
||||
descriptionInputRef,
|
||||
setIsCreatingTask,
|
||||
@@ -436,31 +441,21 @@ export function useTaskSubmitHandlers({
|
||||
const description = descriptionInputRef.current?.getValue() ?? "";
|
||||
const trimmedDescription = description.trim();
|
||||
const attachments = toMessageAttachments(descriptionInputRef.current?.getAttachments() ?? []);
|
||||
if (
|
||||
!validateCreateInputs({
|
||||
trimmedTitle,
|
||||
workspaceId,
|
||||
effectiveWorkflowId,
|
||||
repositoryId,
|
||||
selectedLocalRepo,
|
||||
githubUrl,
|
||||
agentProfileId,
|
||||
})
|
||||
)
|
||||
return;
|
||||
const repositoriesPayload = getRepositoriesPayload();
|
||||
if (!validateForCreate(trimmedTitle)) return;
|
||||
const consent = await ensureFreshBranchConsent();
|
||||
if (consent === null) return;
|
||||
setIsCreatingTask(true);
|
||||
try {
|
||||
if (trimmedDescription || isPassthroughProfile) {
|
||||
await performCreateWithAgent(
|
||||
await performCreate({
|
||||
trimmedTitle,
|
||||
trimmedDescription,
|
||||
repositoriesPayload,
|
||||
undefined,
|
||||
consented: consent,
|
||||
withAgent: true,
|
||||
attachments,
|
||||
);
|
||||
});
|
||||
} else {
|
||||
await handleCreatePlanMode(trimmedTitle, repositoriesPayload);
|
||||
await handleCreatePlanMode(trimmedTitle, consent);
|
||||
}
|
||||
} catch (error) {
|
||||
toast({
|
||||
@@ -473,15 +468,10 @@ export function useTaskSubmitHandlers({
|
||||
}
|
||||
}, [
|
||||
taskName,
|
||||
workspaceId,
|
||||
effectiveWorkflowId,
|
||||
repositoryId,
|
||||
selectedLocalRepo,
|
||||
githubUrl,
|
||||
agentProfileId,
|
||||
isPassthroughProfile,
|
||||
getRepositoriesPayload,
|
||||
performCreateWithAgent,
|
||||
validateForCreate,
|
||||
ensureFreshBranchConsent,
|
||||
performCreate,
|
||||
handleCreatePlanMode,
|
||||
toast,
|
||||
descriptionInputRef,
|
||||
@@ -491,36 +481,31 @@ export function useTaskSubmitHandlers({
|
||||
const handleCreateWithoutAgent = useCallback(async () => {
|
||||
const trimmedTitle = taskName.trim();
|
||||
const trimmedDescription = (descriptionInputRef.current?.getValue() ?? "").trim();
|
||||
if (
|
||||
!validateCreateInputs({
|
||||
trimmedTitle,
|
||||
workspaceId,
|
||||
effectiveWorkflowId,
|
||||
repositoryId,
|
||||
selectedLocalRepo,
|
||||
githubUrl,
|
||||
agentProfileId,
|
||||
})
|
||||
)
|
||||
return;
|
||||
if (!validateForCreate(trimmedTitle)) return;
|
||||
if (!trimmedDescription || !effectiveDefaultStepId || !workspaceId || !effectiveWorkflowId)
|
||||
return;
|
||||
|
||||
const consent = await ensureFreshBranchConsent();
|
||||
if (consent === null) return;
|
||||
setIsCreatingTask(true);
|
||||
try {
|
||||
const payload = buildCreateTaskPayload({
|
||||
workspaceId,
|
||||
effectiveWorkflowId,
|
||||
trimmedTitle,
|
||||
trimmedDescription,
|
||||
repositoriesPayload: getRepositoriesPayload(),
|
||||
agentProfileId,
|
||||
executorId,
|
||||
executorProfileId,
|
||||
withAgent: false,
|
||||
});
|
||||
payload.workflow_step_id = effectiveDefaultStepId;
|
||||
const taskResponse = await createTask(payload);
|
||||
const buildPayload = (c: string[]) => {
|
||||
const p = buildCreateTaskPayload({
|
||||
workspaceId,
|
||||
effectiveWorkflowId,
|
||||
trimmedTitle,
|
||||
trimmedDescription,
|
||||
repositoriesPayload: getRepositoriesPayload(c),
|
||||
agentProfileId,
|
||||
executorId,
|
||||
executorProfileId,
|
||||
withAgent: false,
|
||||
});
|
||||
p.workflow_step_id = effectiveDefaultStepId;
|
||||
return p;
|
||||
};
|
||||
const taskResponse = await createTaskWithFreshBranchRetry(buildPayload, consent);
|
||||
if (!taskResponse) return;
|
||||
onSuccess?.(taskResponse, "create");
|
||||
clearDraft();
|
||||
onOpenChange(false);
|
||||
@@ -537,14 +522,14 @@ export function useTaskSubmitHandlers({
|
||||
taskName,
|
||||
workspaceId,
|
||||
effectiveWorkflowId,
|
||||
repositoryId,
|
||||
selectedLocalRepo,
|
||||
githubUrl,
|
||||
agentProfileId,
|
||||
effectiveDefaultStepId,
|
||||
executorId,
|
||||
executorProfileId,
|
||||
validateForCreate,
|
||||
getRepositoriesPayload,
|
||||
ensureFreshBranchConsent,
|
||||
createTaskWithFreshBranchRetry,
|
||||
onSuccess,
|
||||
onOpenChange,
|
||||
clearDraft,
|
||||
@@ -575,5 +560,6 @@ export function useTaskSubmitHandlers({
|
||||
handleCreateWithoutAgent,
|
||||
handleCreateWithPlanMode,
|
||||
handleCancel,
|
||||
pendingDiscard,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -166,6 +166,12 @@ export type DialogFormState = {
|
||||
setWorkflowAgentProfileId: (v: string) => void;
|
||||
/** Clear draft on successful submission (before closing dialog) */
|
||||
clearDraft: () => void;
|
||||
/** Local executor only: opt-in to discard local changes and start the task on a new branch */
|
||||
freshBranchEnabled: boolean;
|
||||
setFreshBranchEnabled: (v: boolean) => void;
|
||||
/** Currently checked-out branch in the selected local repo (for the disabled selector placeholder) */
|
||||
currentLocalBranch: string;
|
||||
setCurrentLocalBranch: (v: string) => void;
|
||||
};
|
||||
|
||||
export type SubmitHandlersDeps = {
|
||||
@@ -216,4 +222,8 @@ export type SubmitHandlersDeps = {
|
||||
setSelectedWorkflowId: (v: string | null) => void;
|
||||
setFetchedSteps: (v: null) => void;
|
||||
clearDraft: () => void;
|
||||
freshBranchEnabled: boolean;
|
||||
isLocalExecutor: boolean;
|
||||
/** Resolved on-disk path for the selected repository (workspace or discovered). Empty if not local. */
|
||||
repositoryLocalPath: string;
|
||||
};
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { FormEvent, useCallback } from "react";
|
||||
import type { JiraTicket } from "@/lib/types/jira";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@kandev/ui/dialog";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogFooter } from "@kandev/ui/dialog";
|
||||
import type { Task } from "@/lib/types/http";
|
||||
import type { AgentProfileOption } from "@/lib/state/slices";
|
||||
import { SHORTCUTS } from "@/lib/keyboard/constants";
|
||||
@@ -10,23 +10,19 @@ 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 {
|
||||
useRepositoryOptions,
|
||||
useBranchOptions,
|
||||
useAgentProfileOptions,
|
||||
} from "@/components/task-create-dialog-options";
|
||||
import {
|
||||
RepositorySelector,
|
||||
BranchSelector,
|
||||
AgentSelector,
|
||||
ExecutorProfileSelector,
|
||||
InlineTaskName,
|
||||
} from "@/components/task-create-dialog-selectors";
|
||||
import { useTaskSubmitHandlers } from "@/components/task-create-dialog-submit";
|
||||
import { useToast } from "@/components/toast-provider";
|
||||
@@ -75,164 +71,6 @@ interface TaskCreateDialogProps {
|
||||
parentTaskId?: string;
|
||||
}
|
||||
|
||||
function getRepositoryPlaceholder(
|
||||
workspaceId: string | null,
|
||||
repositoriesLoading: boolean,
|
||||
discoverReposLoading: boolean,
|
||||
) {
|
||||
if (!workspaceId) return "Select workspace first";
|
||||
if (repositoriesLoading || discoverReposLoading) return "Loading...";
|
||||
return "Select repository";
|
||||
}
|
||||
|
||||
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<typeof useRepositoryOptions>["headerRepositoryOptions"];
|
||||
onRepositoryChange: (v: string) => void;
|
||||
onTaskNameChange: (v: string) => void;
|
||||
useGitHubUrl: boolean;
|
||||
githubUrl: string;
|
||||
githubUrlError: string | null;
|
||||
onToggleGitHubUrl: () => void;
|
||||
onGitHubUrlChange: (v: string) => void;
|
||||
};
|
||||
|
||||
function RepoSourceInput({
|
||||
useGitHubUrl,
|
||||
githubUrl,
|
||||
githubUrlError,
|
||||
onGitHubUrlChange,
|
||||
isTaskStarted,
|
||||
headerRepositoryOptions,
|
||||
repositoryId,
|
||||
discoveredRepoPath,
|
||||
onRepositoryChange,
|
||||
workspaceId,
|
||||
repositoriesLoading,
|
||||
discoverReposLoading,
|
||||
}: Pick<
|
||||
DialogHeaderContentProps,
|
||||
| "useGitHubUrl"
|
||||
| "githubUrl"
|
||||
| "githubUrlError"
|
||||
| "onGitHubUrlChange"
|
||||
| "isTaskStarted"
|
||||
| "headerRepositoryOptions"
|
||||
| "repositoryId"
|
||||
| "discoveredRepoPath"
|
||||
| "onRepositoryChange"
|
||||
| "workspaceId"
|
||||
| "repositoriesLoading"
|
||||
| "discoverReposLoading"
|
||||
>): React.ReactNode {
|
||||
if (useGitHubUrl) {
|
||||
return (
|
||||
<div className="relative">
|
||||
<input
|
||||
type="text"
|
||||
value={githubUrl}
|
||||
onChange={(e) => 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 && (
|
||||
<div
|
||||
id="github-url-error"
|
||||
role="alert"
|
||||
className="absolute left-0 top-full mt-1 z-50 rounded-md border bg-popover px-2 py-1 text-[11px] text-destructive shadow-md whitespace-nowrap"
|
||||
data-testid="github-url-error"
|
||||
>
|
||||
{githubUrlError}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<RepositorySelector
|
||||
options={headerRepositoryOptions}
|
||||
value={repositoryId || discoveredRepoPath}
|
||||
onValueChange={onRepositoryChange}
|
||||
placeholder={getRepositoryPlaceholder(workspaceId, repositoriesLoading, discoverReposLoading)}
|
||||
searchPlaceholder="Search repositories..."
|
||||
emptyMessage={
|
||||
repositoriesLoading || discoverReposLoading
|
||||
? "Loading repositories..."
|
||||
: "No repositories found."
|
||||
}
|
||||
disabled={isTaskStarted || !workspaceId || repositoriesLoading || discoverReposLoading}
|
||||
triggerClassName="w-auto text-sm"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DialogHeaderContent(props: DialogHeaderContentProps) {
|
||||
const { isCreateMode, isEditMode, isTaskStarted, sessionRepoName, initialTitle } = props;
|
||||
const { taskName, onTaskNameChange, useGitHubUrl, onToggleGitHubUrl } = props;
|
||||
|
||||
if (isCreateMode || isEditMode) {
|
||||
return (
|
||||
<>
|
||||
<DialogTitle asChild>
|
||||
<div className="flex items-center gap-1 text-sm font-medium min-w-0">
|
||||
<RepoSourceInput {...props} />
|
||||
<span className="text-muted-foreground mr-2">/</span>
|
||||
<InlineTaskName
|
||||
value={taskName}
|
||||
onChange={onTaskNameChange}
|
||||
autoFocus={!isEditMode && !useGitHubUrl}
|
||||
/>
|
||||
</div>
|
||||
</DialogTitle>
|
||||
{!isTaskStarted && (
|
||||
<div className="flex items-center gap-2 pl-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleGitHubUrl}
|
||||
className="text-xs text-muted-foreground hover:text-foreground cursor-pointer transition-colors"
|
||||
data-testid="toggle-github-url"
|
||||
>
|
||||
{useGitHubUrl ? "or select a repository" : "or paste a GitHub URL"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<DialogTitle asChild>
|
||||
<div className="flex items-center gap-1 min-w-0 text-sm font-medium">
|
||||
{sessionRepoName && (
|
||||
<>
|
||||
<span className="truncate text-muted-foreground">{sessionRepoName}</span>
|
||||
<span className="text-muted-foreground mx-0.5">/</span>
|
||||
</>
|
||||
)}
|
||||
<span className="truncate">{initialTitle || "Task"}</span>
|
||||
<span className="text-muted-foreground mx-0.5">/</span>
|
||||
<span className="text-muted-foreground whitespace-nowrap">new session</span>
|
||||
</div>
|
||||
</DialogTitle>
|
||||
);
|
||||
}
|
||||
|
||||
type DialogFormBodyProps = {
|
||||
isSessionMode: boolean;
|
||||
isCreateMode: boolean;
|
||||
@@ -267,6 +105,7 @@ type DialogFormBodyProps = {
|
||||
isLocalExecutor: boolean;
|
||||
enhance?: { onEnhance: () => void; isLoading: boolean; isConfigured: boolean };
|
||||
workflowAgentLocked: boolean;
|
||||
onToggleFreshBranch: (enabled: boolean) => void;
|
||||
};
|
||||
|
||||
function DialogFormBody({
|
||||
@@ -299,6 +138,7 @@ function DialogFormBody({
|
||||
isLocalExecutor,
|
||||
enhance,
|
||||
workflowAgentLocked,
|
||||
onToggleFreshBranch,
|
||||
}: DialogFormBodyProps) {
|
||||
return (
|
||||
<div className="flex-1 space-y-4 overflow-y-auto pr-1">
|
||||
@@ -314,33 +154,26 @@ function DialogFormBody({
|
||||
workspaceId={workspaceId}
|
||||
onJiraImport={onJiraImport}
|
||||
/>
|
||||
{!isSessionMode && (
|
||||
<CreateEditSelectors
|
||||
isTaskStarted={isTaskStarted}
|
||||
hasRepositorySelection={hasRepositorySelection}
|
||||
branchOptions={branchOptions}
|
||||
branch={fs.branch}
|
||||
onBranchChange={onBranchChange}
|
||||
branchesLoading={branchesLoading}
|
||||
localBranchesLoading={fs.localBranchesLoading}
|
||||
agentProfiles={agentProfiles}
|
||||
agentProfilesLoading={agentProfilesLoading}
|
||||
agentProfileOptions={agentProfileOptions}
|
||||
agentProfileId={fs.agentProfileId}
|
||||
onAgentProfileChange={onAgentProfileChange}
|
||||
isCreatingSession={isCreatingSession}
|
||||
executorProfileOptions={executorProfileOptions}
|
||||
executorProfileId={fs.executorProfileId}
|
||||
onExecutorProfileChange={onExecutorProfileChange}
|
||||
executorsLoading={executorsLoading}
|
||||
isLocalExecutor={isLocalExecutor}
|
||||
useGitHubUrl={fs.useGitHubUrl}
|
||||
workflowAgentLocked={workflowAgentLocked}
|
||||
BranchSelectorComponent={BranchSelector}
|
||||
AgentSelectorComponent={AgentSelector}
|
||||
ExecutorProfileSelectorComponent={ExecutorProfileSelector}
|
||||
/>
|
||||
)}
|
||||
{!isSessionMode &&
|
||||
renderCreateEditSelectors({
|
||||
isTaskStarted,
|
||||
hasRepositorySelection,
|
||||
branchOptions,
|
||||
branchesLoading,
|
||||
agentProfiles,
|
||||
agentProfilesLoading,
|
||||
agentProfileOptions,
|
||||
isCreatingSession,
|
||||
executorProfileOptions,
|
||||
executorsLoading,
|
||||
isLocalExecutor,
|
||||
workflowAgentLocked,
|
||||
fs,
|
||||
onBranchChange,
|
||||
onAgentProfileChange,
|
||||
onExecutorProfileChange,
|
||||
onToggleFreshBranch,
|
||||
})}
|
||||
<WorkflowSection
|
||||
isCreateMode={isCreateMode}
|
||||
isTaskStarted={isTaskStarted}
|
||||
@@ -369,6 +202,61 @@ function DialogFormBody({
|
||||
);
|
||||
}
|
||||
|
||||
type CreateEditSelectorsRenderArgs = Pick<
|
||||
DialogFormBodyProps,
|
||||
| "isTaskStarted"
|
||||
| "hasRepositorySelection"
|
||||
| "branchOptions"
|
||||
| "branchesLoading"
|
||||
| "agentProfiles"
|
||||
| "agentProfilesLoading"
|
||||
| "agentProfileOptions"
|
||||
| "isCreatingSession"
|
||||
| "executorProfileOptions"
|
||||
| "executorsLoading"
|
||||
| "isLocalExecutor"
|
||||
| "workflowAgentLocked"
|
||||
| "fs"
|
||||
| "onBranchChange"
|
||||
| "onAgentProfileChange"
|
||||
| "onExecutorProfileChange"
|
||||
| "onToggleFreshBranch"
|
||||
>;
|
||||
|
||||
function renderCreateEditSelectors(args: CreateEditSelectorsRenderArgs) {
|
||||
const { fs } = args;
|
||||
return (
|
||||
<CreateEditSelectors
|
||||
isTaskStarted={args.isTaskStarted}
|
||||
hasRepositorySelection={args.hasRepositorySelection}
|
||||
branchOptions={args.branchOptions}
|
||||
branch={fs.branch}
|
||||
onBranchChange={args.onBranchChange}
|
||||
branchesLoading={args.branchesLoading}
|
||||
localBranchesLoading={fs.localBranchesLoading}
|
||||
agentProfiles={args.agentProfiles}
|
||||
agentProfilesLoading={args.agentProfilesLoading}
|
||||
agentProfileOptions={args.agentProfileOptions}
|
||||
agentProfileId={fs.agentProfileId}
|
||||
onAgentProfileChange={args.onAgentProfileChange}
|
||||
isCreatingSession={args.isCreatingSession}
|
||||
executorProfileOptions={args.executorProfileOptions}
|
||||
executorProfileId={fs.executorProfileId}
|
||||
onExecutorProfileChange={args.onExecutorProfileChange}
|
||||
executorsLoading={args.executorsLoading}
|
||||
isLocalExecutor={args.isLocalExecutor}
|
||||
useGitHubUrl={fs.useGitHubUrl}
|
||||
workflowAgentLocked={args.workflowAgentLocked}
|
||||
freshBranchEnabled={fs.freshBranchEnabled}
|
||||
onToggleFreshBranch={args.onToggleFreshBranch}
|
||||
currentLocalBranch={fs.currentLocalBranch}
|
||||
BranchSelectorComponent={BranchSelector}
|
||||
AgentSelectorComponent={AgentSelector}
|
||||
ExecutorProfileSelectorComponent={ExecutorProfileSelector}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function useEnhanceForDialog(fs: DialogFormState) {
|
||||
const isConfigured = useIsUtilityConfigured();
|
||||
const { enhancePrompt, isEnhancingPrompt } = useUtilityAgentGenerator({
|
||||
@@ -402,43 +290,27 @@ function useJiraImportHandler(fs: DialogFormState) {
|
||||
);
|
||||
}
|
||||
|
||||
function useTaskCreateDialogSetup(props: TaskCreateDialogProps) {
|
||||
const { open, onOpenChange, mode = "create", workspaceId, workflowId, defaultStepId } = props;
|
||||
const { editingTask, onSuccess, onCreateSession, initialValues, parentTaskId } = props;
|
||||
type SubmitWiringArgs = {
|
||||
props: TaskCreateDialogProps;
|
||||
fs: ReturnType<typeof useDialogFormState>;
|
||||
computed: ReturnType<typeof useTaskCreateDialogData>["computed"];
|
||||
repositoryLocalPath: string;
|
||||
isSessionMode: boolean;
|
||||
isEditMode: boolean;
|
||||
};
|
||||
|
||||
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;
|
||||
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,
|
||||
branches,
|
||||
branchesLoading,
|
||||
computed,
|
||||
} = useTaskCreateDialogData(open, workspaceId, workflowId, defaultStepId, fs);
|
||||
useTaskCreateDialogEffects(fs, {
|
||||
open,
|
||||
workspaceId,
|
||||
workflowId,
|
||||
repositories,
|
||||
repositoriesLoading,
|
||||
branches,
|
||||
agentProfiles,
|
||||
executors,
|
||||
workspaceDefaults: computed.workspaceDefaults,
|
||||
toast,
|
||||
workflows,
|
||||
});
|
||||
const handlers = useDialogHandlers(fs, repositories);
|
||||
const submitHandlers = useTaskSubmitHandlers({
|
||||
return useTaskSubmitHandlers({
|
||||
isSessionMode,
|
||||
isEditMode,
|
||||
isPassthroughProfile: computed.isPassthroughProfile,
|
||||
@@ -475,6 +347,62 @@ function useTaskCreateDialogSetup(props: TaskCreateDialogProps) {
|
||||
setSelectedWorkflowId: fs.setSelectedWorkflowId,
|
||||
setFetchedSteps: fs.setFetchedSteps,
|
||||
clearDraft: fs.clearDraft,
|
||||
freshBranchEnabled: fs.freshBranchEnabled,
|
||||
isLocalExecutor: computed.isLocalExecutor,
|
||||
repositoryLocalPath,
|
||||
});
|
||||
}
|
||||
|
||||
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,
|
||||
branches,
|
||||
branchesLoading,
|
||||
computed,
|
||||
} = useTaskCreateDialogData(open, workspaceId, workflowId, defaultStepId, fs);
|
||||
const repositoryLocalPath = (() => {
|
||||
if (fs.selectedLocalRepo) return fs.selectedLocalRepo.path;
|
||||
if (fs.repositoryId) {
|
||||
const repo = repositories.find((r) => r.id === fs.repositoryId);
|
||||
return repo?.local_path ?? "";
|
||||
}
|
||||
return "";
|
||||
})();
|
||||
useTaskCreateDialogEffects(fs, {
|
||||
open,
|
||||
workspaceId,
|
||||
workflowId,
|
||||
repositories,
|
||||
repositoriesLoading,
|
||||
branches,
|
||||
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);
|
||||
@@ -569,6 +497,7 @@ export function TaskCreateDialog(props: TaskCreateDialogProps) {
|
||||
isLocalExecutor={computed.isLocalExecutor}
|
||||
enhance={setup.enhance}
|
||||
workflowAgentLocked={computed.workflowAgentLocked}
|
||||
onToggleFreshBranch={handlers.handleToggleFreshBranch}
|
||||
/>
|
||||
<DialogFooter className="border-t border-border pt-3 flex-col gap-3 sm:flex-row sm:gap-2">
|
||||
<TaskCreateDialogFooter
|
||||
@@ -594,7 +523,28 @@ export function TaskCreateDialog(props: TaskCreateDialogProps) {
|
||||
/>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
<PendingDiscardModal pending={setup.submitHandlers.pendingDiscard} />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function PendingDiscardModal({
|
||||
pending,
|
||||
}: {
|
||||
pending: ReturnType<typeof useTaskSubmitHandlers>["pendingDiscard"];
|
||||
}) {
|
||||
if (!pending) return null;
|
||||
return (
|
||||
<DiscardLocalChangesDialog
|
||||
open
|
||||
onOpenChange={(next) => {
|
||||
if (!next) pending.resolve(false);
|
||||
}}
|
||||
dirtyFiles={pending.dirtyFiles}
|
||||
repoPath={pending.repoPath}
|
||||
onConfirm={() => pending.resolve(true)}
|
||||
onCancel={() => pending.resolve(false)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import path from "node:path";
|
||||
import fs from "node:fs";
|
||||
import { execSync } from "node:child_process";
|
||||
import { test, expect } from "../../fixtures/test-base";
|
||||
import { KanbanPage } from "../../pages/kanban-page";
|
||||
import { makeGitEnv } from "../../helpers/git-helper";
|
||||
|
||||
test.describe("Branch selector behavior with executor types", () => {
|
||||
test.describe.configure({ retries: 1 });
|
||||
@@ -75,7 +79,7 @@ test.describe("Branch selector behavior with executor types", () => {
|
||||
// Select local executor -> branch should be disabled
|
||||
const executorSelector = testPage.getByTestId("executor-profile-selector");
|
||||
await executorSelector.click();
|
||||
await testPage.getByRole("option", { name: /E2E Local/i }).click();
|
||||
await testPage.getByRole("option", { name: /^E2E Local Local$/i }).click();
|
||||
|
||||
const branchSelector = testPage.getByTestId("branch-selector");
|
||||
await expect(branchSelector).toBeDisabled({ timeout: 5_000 });
|
||||
@@ -161,3 +165,356 @@ test.describe("Branch selector behavior with executor types", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Fresh-branch flow tests.
|
||||
*
|
||||
* Each test seeds an isolated repo inside backend.tmpDir so the discovery roots
|
||||
* check passes, then opens the create-task dialog and exercises the toggle path.
|
||||
*
|
||||
* Important: the seedData repo is shared across the worker, so we mutate or
|
||||
* inspect a NEW repo per test to avoid cross-test interference. We register the
|
||||
* repo via apiClient.createRepository so it shows up in the workspace selector.
|
||||
*/
|
||||
test.describe("Fresh-branch flow", () => {
|
||||
test.describe.configure({ retries: 1 });
|
||||
|
||||
type Setup = {
|
||||
repoDir: string;
|
||||
repositoryId: string;
|
||||
profileId: string;
|
||||
profileName: string;
|
||||
};
|
||||
|
||||
type ApiClientType = import("../../helpers/api-client").ApiClient;
|
||||
|
||||
async function setupLocalRepo(
|
||||
apiClient: ApiClientType,
|
||||
backendTmpDir: string,
|
||||
workspaceId: string,
|
||||
suffix: string,
|
||||
): Promise<(Setup & { repoName: string }) | null> {
|
||||
const { executors } = await apiClient.listExecutors();
|
||||
const localExec = executors.find((e) => e.type === "local");
|
||||
if (!localExec) return null;
|
||||
const profileName = `E2E Fresh Branch ${suffix}`;
|
||||
const profile = await apiClient.createExecutorProfile(localExec.id, profileName);
|
||||
|
||||
const repoName = `E2E Fresh Repo ${suffix}`;
|
||||
const repoDir = path.join(backendTmpDir, "repos", `e2e-fresh-branch-${suffix}`);
|
||||
fs.mkdirSync(repoDir, { recursive: true });
|
||||
const env = makeGitEnv(backendTmpDir);
|
||||
execSync("git init -b main", { cwd: repoDir, env });
|
||||
execSync('git commit --allow-empty -m "init"', { cwd: repoDir, env });
|
||||
execSync("git checkout -b develop", { cwd: repoDir, env });
|
||||
execSync('git commit --allow-empty -m "develop"', { cwd: repoDir, env });
|
||||
execSync("git checkout main", { cwd: repoDir, env });
|
||||
const repo = await apiClient.createRepository(workspaceId, repoDir, "main", { name: repoName });
|
||||
return { repoDir, repositoryId: repo.id, profileId: profile.id, profileName, repoName };
|
||||
}
|
||||
|
||||
async function openDialogWithLocalProfile(
|
||||
testPage: import("@playwright/test").Page,
|
||||
profileName: string,
|
||||
repoName: string,
|
||||
) {
|
||||
const kanban = new KanbanPage(testPage);
|
||||
await kanban.goto();
|
||||
await kanban.createTaskButton.first().click();
|
||||
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
|
||||
.getByRole("option", { name: new RegExp(`^${escapeRe(repoName)}\\b`, "i") })
|
||||
.first()
|
||||
.click();
|
||||
await testPage.getByTestId("executor-profile-selector").click();
|
||||
await testPage
|
||||
.getByRole("option", { name: new RegExp(`^${escapeRe(profileName)}\\b`, "i") })
|
||||
.first()
|
||||
.click();
|
||||
}
|
||||
|
||||
function escapeRe(s: string) {
|
||||
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
test("toggle off (default) — branch selector disabled, placeholder shows current branch", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
backend,
|
||||
seedData,
|
||||
}) => {
|
||||
const setup = await setupLocalRepo(apiClient, backend.tmpDir, seedData.workspaceId, "default");
|
||||
if (!setup) {
|
||||
test.skip(true, "No local executor available");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await openDialogWithLocalProfile(testPage, setup.profileName, setup.repoName);
|
||||
const toggle = testPage.getByTestId("fresh-branch-toggle");
|
||||
await expect(toggle).toBeVisible();
|
||||
await expect(toggle).toHaveAttribute("aria-pressed", "false");
|
||||
const branchSelector = testPage.getByTestId("branch-selector");
|
||||
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 });
|
||||
} finally {
|
||||
await apiClient.deleteExecutorProfile(setup.profileId).catch(() => {});
|
||||
}
|
||||
});
|
||||
|
||||
test("toggle on, clean working tree — selector enabled, no discard modal on submit", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
backend,
|
||||
seedData,
|
||||
}) => {
|
||||
const setup = await setupLocalRepo(apiClient, backend.tmpDir, seedData.workspaceId, "clean");
|
||||
if (!setup) {
|
||||
test.skip(true, "No local executor available");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await openDialogWithLocalProfile(testPage, setup.profileName, setup.repoName);
|
||||
await testPage.getByTestId("fresh-branch-toggle").click();
|
||||
const branchSelector = testPage.getByTestId("branch-selector");
|
||||
await expect(branchSelector).toBeEnabled({ timeout: 5_000 });
|
||||
// Pick the develop base branch so the new branch will fork from it.
|
||||
await branchSelector.click();
|
||||
await testPage
|
||||
.getByRole("option", { name: /develop/ })
|
||||
.first()
|
||||
.click();
|
||||
|
||||
// Submit and assert the discard modal never appears (clean tree).
|
||||
// Wait for the create-task request to fire so we know the submit path
|
||||
// really executed and didn't short-circuit before the modal would render.
|
||||
const createTaskRequest = testPage.waitForRequest(
|
||||
(req) => req.url().endsWith("/api/v1/tasks") && req.method() === "POST",
|
||||
);
|
||||
await testPage.getByTestId("submit-start-agent").click();
|
||||
await createTaskRequest;
|
||||
await expect(testPage.getByTestId("discard-local-changes-dialog")).toHaveCount(0);
|
||||
} finally {
|
||||
await apiClient.deleteExecutorProfile(setup.profileId).catch(() => {});
|
||||
}
|
||||
});
|
||||
|
||||
test("toggle on, dirty working tree — confirm modal lists files; cancel keeps form state", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
backend,
|
||||
seedData,
|
||||
}) => {
|
||||
const setup = await setupLocalRepo(apiClient, backend.tmpDir, seedData.workspaceId, "dirty");
|
||||
if (!setup) {
|
||||
test.skip(true, "No local executor available");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
// Add an untracked file so `git status` reports it as dirty.
|
||||
fs.writeFileSync(path.join(setup.repoDir, "WIP.txt"), "draft");
|
||||
|
||||
await openDialogWithLocalProfile(testPage, setup.profileName, setup.repoName);
|
||||
await testPage.getByTestId("fresh-branch-toggle").click();
|
||||
// Submit triggers the dirty preflight; the modal lists WIP.txt.
|
||||
await testPage.getByTestId("submit-start-agent").click();
|
||||
|
||||
const modal = testPage.getByTestId("discard-local-changes-dialog");
|
||||
await expect(modal).toBeVisible({ timeout: 5_000 });
|
||||
await expect(testPage.getByTestId("discard-local-changes-files")).toContainText("WIP.txt");
|
||||
|
||||
// Cancel returns to the form with the toggle still on.
|
||||
await testPage.getByTestId("discard-local-changes-cancel").click();
|
||||
await expect(modal).toBeHidden();
|
||||
await expect(testPage.getByTestId("fresh-branch-toggle")).toHaveAttribute(
|
||||
"aria-pressed",
|
||||
"true",
|
||||
);
|
||||
|
||||
// Untracked file must still exist (we didn't confirm).
|
||||
expect(fs.existsSync(path.join(setup.repoDir, "WIP.txt"))).toBe(true);
|
||||
} finally {
|
||||
await apiClient.deleteExecutorProfile(setup.profileId).catch(() => {});
|
||||
}
|
||||
});
|
||||
|
||||
test("dirty working tree — confirm overwrite removes the file and proceeds", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
backend,
|
||||
seedData,
|
||||
}) => {
|
||||
const setup = await setupLocalRepo(apiClient, backend.tmpDir, seedData.workspaceId, "confirm");
|
||||
if (!setup) {
|
||||
test.skip(true, "No local executor available");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const wipPath = path.join(setup.repoDir, "WIP-confirm.txt");
|
||||
fs.writeFileSync(wipPath, "draft");
|
||||
|
||||
await openDialogWithLocalProfile(testPage, setup.profileName, setup.repoName);
|
||||
await testPage.getByTestId("fresh-branch-toggle").click();
|
||||
await testPage.getByTestId("submit-start-agent").click();
|
||||
|
||||
await expect(testPage.getByTestId("discard-local-changes-dialog")).toBeVisible({
|
||||
timeout: 5_000,
|
||||
});
|
||||
await testPage.getByTestId("discard-local-changes-confirm").click();
|
||||
|
||||
// After confirm the backend runs reset --hard + clean -fd: the untracked
|
||||
// file must be gone.
|
||||
await expect.poll(() => fs.existsSync(wipPath), { timeout: 10_000 }).toBe(false);
|
||||
} finally {
|
||||
await apiClient.deleteExecutorProfile(setup.profileId).catch(() => {});
|
||||
}
|
||||
});
|
||||
|
||||
test("truncated dirty list — many dirty files show '+N more'", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
backend,
|
||||
seedData,
|
||||
}) => {
|
||||
const setup = await setupLocalRepo(apiClient, backend.tmpDir, seedData.workspaceId, "many");
|
||||
if (!setup) {
|
||||
test.skip(true, "No local executor available");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
// Seed 25 untracked files so the modal cap (20) kicks in.
|
||||
for (let i = 0; i < 25; i++) {
|
||||
fs.writeFileSync(path.join(setup.repoDir, `f${i}.txt`), "x");
|
||||
}
|
||||
await openDialogWithLocalProfile(testPage, setup.profileName, setup.repoName);
|
||||
await testPage.getByTestId("fresh-branch-toggle").click();
|
||||
await testPage.getByTestId("submit-start-agent").click();
|
||||
|
||||
await expect(testPage.getByTestId("discard-local-changes-dialog")).toBeVisible({
|
||||
timeout: 5_000,
|
||||
});
|
||||
await expect(testPage.getByTestId("discard-local-changes-overflow")).toContainText("+5 more");
|
||||
} finally {
|
||||
await apiClient.deleteExecutorProfile(setup.profileId).catch(() => {});
|
||||
}
|
||||
});
|
||||
|
||||
test("GitHub URL flow hides fresh-branch toggle", async ({ testPage, apiClient }) => {
|
||||
const { executors } = await apiClient.listExecutors();
|
||||
const localExec = executors.find((e) => e.type === "local");
|
||||
if (!localExec) {
|
||||
test.skip(true, "No local executor available");
|
||||
return;
|
||||
}
|
||||
const profile = await apiClient.createExecutorProfile(localExec.id, "E2E Fresh Branch GH");
|
||||
try {
|
||||
const kanban = new KanbanPage(testPage);
|
||||
await kanban.goto();
|
||||
await kanban.createTaskButton.first().click();
|
||||
await expect(testPage.getByTestId("create-task-dialog")).toBeVisible();
|
||||
await testPage.getByTestId("task-title-input").fill("Hide toggle");
|
||||
await testPage.getByTestId("task-description-input").fill("github url");
|
||||
await testPage.getByTestId("toggle-github-url").click();
|
||||
await testPage
|
||||
.getByTestId("github-url-input")
|
||||
.fill("https://github.com/branch-test-owner/branch-test-repo");
|
||||
await testPage.getByTestId("executor-profile-selector").click();
|
||||
await testPage.getByRole("option", { name: /E2E Fresh Branch GH/i }).click();
|
||||
|
||||
// Toggle is gated behind isLocalExecutor && !useGitHubUrl, so it must not render.
|
||||
await expect(testPage.getByTestId("fresh-branch-toggle")).toHaveCount(0);
|
||||
} finally {
|
||||
await apiClient.deleteExecutorProfile(profile.id).catch(() => {});
|
||||
}
|
||||
});
|
||||
|
||||
test("non-local executor (worktree) hides fresh-branch toggle", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
}) => {
|
||||
if (!seedData.worktreeExecutorProfileId) {
|
||||
test.skip(true, "No worktree executor profile in seed data");
|
||||
return;
|
||||
}
|
||||
// Look up the actual profile name so the option matcher doesn't depend on
|
||||
// the seed naming containing "worktree".
|
||||
const { executors } = await apiClient.listExecutors();
|
||||
const worktreeProfileName = executors
|
||||
.flatMap((e) => e.profiles ?? [])
|
||||
.find((p) => p.id === seedData.worktreeExecutorProfileId)?.name;
|
||||
if (!worktreeProfileName) {
|
||||
test.skip(true, "Could not resolve worktree profile name");
|
||||
return;
|
||||
}
|
||||
|
||||
const kanban = new KanbanPage(testPage);
|
||||
await kanban.goto();
|
||||
await kanban.createTaskButton.first().click();
|
||||
await expect(testPage.getByTestId("create-task-dialog")).toBeVisible();
|
||||
await testPage.getByTestId("task-title-input").fill("No toggle for worktree");
|
||||
await testPage.getByTestId("task-description-input").fill("worktree mode");
|
||||
const executorSelector = testPage.getByTestId("executor-profile-selector");
|
||||
await executorSelector.click();
|
||||
await testPage.getByRole("option", { name: worktreeProfileName }).click();
|
||||
await expect(testPage.getByTestId("fresh-branch-toggle")).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("switching worktree → local resets the disabled selector to current branch", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
backend,
|
||||
seedData,
|
||||
}) => {
|
||||
const setup = await setupLocalRepo(apiClient, backend.tmpDir, seedData.workspaceId, "switch");
|
||||
if (!setup) {
|
||||
test.skip(true, "No local executor available");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const kanban = new KanbanPage(testPage);
|
||||
await kanban.goto();
|
||||
await kanban.createTaskButton.first().click();
|
||||
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
|
||||
.getByRole("option", { name: new RegExp(`^${setup.repoName}\\b`, "i") })
|
||||
.first()
|
||||
.click();
|
||||
|
||||
// Pick the worktree executor first and select the develop branch.
|
||||
const executorSelector = testPage.getByTestId("executor-profile-selector");
|
||||
await executorSelector.click();
|
||||
await testPage
|
||||
.getByRole("option")
|
||||
.filter({ hasText: /worktree/i })
|
||||
.first()
|
||||
.click();
|
||||
const branchSelector = testPage.getByTestId("branch-selector");
|
||||
await expect(branchSelector).toBeEnabled({ timeout: 5_000 });
|
||||
await branchSelector.click();
|
||||
await testPage
|
||||
.getByRole("option", { name: /develop/ })
|
||||
.first()
|
||||
.click();
|
||||
await expect(branchSelector).toContainText("develop");
|
||||
|
||||
// Switch back to local — disabled selector must show the actual current branch (main),
|
||||
// not the develop selection that came from the worktree executor.
|
||||
await executorSelector.click();
|
||||
await testPage
|
||||
.getByRole("option", { name: new RegExp(`^${setup.profileName}\\b`, "i") })
|
||||
.first()
|
||||
.click();
|
||||
await expect(branchSelector).toBeDisabled({ timeout: 5_000 });
|
||||
await expect(branchSelector).toContainText("main", { timeout: 5_000 });
|
||||
} finally {
|
||||
await apiClient.deleteExecutorProfile(setup.profileId).catch(() => {});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
+34
-19
@@ -6,6 +6,22 @@ export type ApiRequestOptions = {
|
||||
init?: RequestInit;
|
||||
};
|
||||
|
||||
/**
|
||||
* Error thrown by fetchJson on non-2xx responses. `body` carries the parsed
|
||||
* JSON response body (if any) so callers can react to structured fields like
|
||||
* the dirty_files list returned with HTTP 409.
|
||||
*/
|
||||
export class ApiError extends Error {
|
||||
readonly status: number;
|
||||
readonly body: unknown;
|
||||
constructor(message: string, status: number, body: unknown) {
|
||||
super(message);
|
||||
this.name = "ApiError";
|
||||
this.status = status;
|
||||
this.body = body;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveUrl(pathOrUrl: string, baseUrl: string) {
|
||||
if (pathOrUrl.startsWith("http://") || pathOrUrl.startsWith("https://")) {
|
||||
return pathOrUrl;
|
||||
@@ -13,6 +29,21 @@ function resolveUrl(pathOrUrl: string, baseUrl: string) {
|
||||
return `${baseUrl}${pathOrUrl}`;
|
||||
}
|
||||
|
||||
async function throwFromResponse(response: Response): Promise<never> {
|
||||
let body: unknown = null;
|
||||
let message = `Request failed: ${response.status} ${response.statusText}`;
|
||||
try {
|
||||
body = await response.json();
|
||||
} catch {
|
||||
// body remains null
|
||||
}
|
||||
if (body && typeof body === "object" && "error" in body) {
|
||||
const errVal = (body as { error?: unknown }).error;
|
||||
if (typeof errVal === "string") message = errVal;
|
||||
}
|
||||
throw new ApiError(message, response.status, body);
|
||||
}
|
||||
|
||||
export async function fetchJson<T>(pathOrUrl: string, options?: ApiRequestOptions): Promise<T> {
|
||||
const baseUrl = options?.baseUrl ?? getBackendConfig().apiBaseUrl;
|
||||
const url = resolveUrl(pathOrUrl, baseUrl);
|
||||
@@ -24,25 +55,9 @@ export async function fetchJson<T>(pathOrUrl: string, options?: ApiRequestOption
|
||||
...(options?.init?.headers ?? {}),
|
||||
},
|
||||
});
|
||||
if (!response.ok) {
|
||||
// Try to extract error message from response body
|
||||
let errorMessage = `Request failed: ${response.status} ${response.statusText}`;
|
||||
try {
|
||||
const errorBody = await response.json();
|
||||
if (errorBody?.error) {
|
||||
errorMessage = errorBody.error;
|
||||
}
|
||||
} catch {
|
||||
// Ignore JSON parse errors, use default message
|
||||
}
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
if (response.status === 204) {
|
||||
return undefined as T;
|
||||
}
|
||||
if (!response.ok) await throwFromResponse(response);
|
||||
if (response.status === 204) return undefined as T;
|
||||
const text = await response.text();
|
||||
if (!text) {
|
||||
return undefined as T;
|
||||
}
|
||||
if (!text) return undefined as T;
|
||||
return JSON.parse(text) as T;
|
||||
}
|
||||
|
||||
@@ -53,6 +53,9 @@ export async function createTask(
|
||||
name?: string;
|
||||
default_branch?: string;
|
||||
github_url?: string;
|
||||
fresh_branch?: boolean;
|
||||
confirm_discard?: boolean;
|
||||
consented_dirty_files?: string[];
|
||||
}>;
|
||||
state?: Task["state"];
|
||||
start_agent?: boolean;
|
||||
|
||||
@@ -494,6 +494,12 @@ export type Branch = {
|
||||
export type RepositoryBranchesResponse = {
|
||||
branches: Branch[];
|
||||
total: number;
|
||||
current_branch?: string;
|
||||
};
|
||||
|
||||
export type LocalRepositoryStatusResponse = {
|
||||
current_branch: string;
|
||||
dirty_files: string[];
|
||||
};
|
||||
|
||||
export type ListWorkspacesResponse = {
|
||||
|
||||
Reference in New Issue
Block a user