feat(workspaces): save repository set base branches (#3269)

This commit is contained in:
Carlos Florêncio
2026-09-11 16:18:10 +01:00
committed by GitHub
parent a4077cf9cf
commit e64d8e4cf3
89 changed files with 4481 additions and 857 deletions
+19
View File
@@ -598,6 +598,25 @@ jobs:
- name: Verify Docker is available
run: docker info
# Pull the immutable application-image base before the Playwright test.
# The fixture image only copies this run's artifacts, so package mirrors
# are not on the critical path of every PR's compatibility test.
- name: Pull pinned Kubernetes fixture base image
env:
KUBERNETES_E2E_BASE_IMAGE: ghcr.io/kdlbs/kandev-ci:runtime-sha-6f288a23c526@sha256:b9636e1c20adb0fcce1c65858a767fb1b0fe48a15ac5688b9c623163575efb8d
run: |
set -euo pipefail
for attempt in 1 2 3; do
if timeout --kill-after=10s 180s docker pull "$KUBERNETES_E2E_BASE_IMAGE"; then
exit 0
fi
if (( attempt < 3 )); then
sleep "$((attempt * 5))"
fi
done
echo "Failed to pull $KUBERNETES_E2E_BASE_IMAGE after 3 attempts." >&2
exit 1
- name: Set up pnpm
uses: pnpm/action-setup@a8198c4bff370c8506180b035930dea56dbd5288 # v5
with:
+2
View File
@@ -105,6 +105,7 @@ type RepositorySetDTO struct {
type RepositorySetItemDTO struct {
RepositoryID string `json:"repository_id"`
Position int `json:"position"`
BaseBranch string `json:"base_branch"`
}
type RepositoryBranchPolicyDTO struct {
@@ -779,6 +780,7 @@ func FromRepositorySet(set *models.RepositorySet) RepositorySetDTO {
items = append(items, RepositorySetItemDTO{
RepositoryID: item.RepositoryID,
Position: item.Position,
BaseBranch: item.BaseBranch,
})
}
return RepositorySetDTO{
@@ -16,7 +16,7 @@ func TestFromRepositorySetCarriesOrderedMembership(t *testing.T) {
Name: "Full-stack",
Description: "web + gateway",
Items: []models.RepositorySetItem{
{ID: "item-1", RepositorySetID: "set-1", RepositoryID: "repo-web", Position: 0},
{ID: "item-1", RepositorySetID: "set-1", RepositoryID: "repo-web", Position: 0, BaseBranch: "develop"},
{ID: "item-2", RepositorySetID: "set-1", RepositoryID: "repo-gateway", Position: 1},
},
CreatedAt: created,
@@ -37,6 +37,9 @@ func TestFromRepositorySetCarriesOrderedMembership(t *testing.T) {
if got.Repositories[0].RepositoryID != "repo-web" || got.Repositories[0].Position != 0 {
t.Fatalf("first member = %+v", got.Repositories[0])
}
if got.Repositories[0].BaseBranch != "develop" {
t.Fatalf("first member base branch = %q, want %q", got.Repositories[0].BaseBranch, "develop")
}
if got.Repositories[1].RepositoryID != "repo-gateway" || got.Repositories[1].Position != 1 {
t.Fatalf("second member = %+v", got.Repositories[1])
}
@@ -0,0 +1,63 @@
package handlers
import (
"encoding/json"
"net/http"
"testing"
"github.com/stretchr/testify/require"
"github.com/kandev/kandev/internal/task/dto"
)
func TestHTTPRepositorySetCarriesMemberBaseBranch(t *testing.T) {
router, _ := newRepositorySetTestRouter(t)
created := createSetViaHTTP(t, router, map[string]any{
"name": "Base branches",
"repositories": []map[string]any{
{"repository_id": "repo-web", "base_branch": "develop"},
},
})
require.Len(t, created.Repositories, 1)
require.Equal(t, "repo-web", created.Repositories[0].RepositoryID)
require.Equal(t, "develop", created.Repositories[0].BaseBranch)
var fetched dto.RepositorySetDTO
recorder := doJSON(t, router, http.MethodGet, "/api/v1/repository-sets/"+created.ID, nil)
require.Equal(t, http.StatusOK, recorder.Code, recorder.Body.String())
require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &fetched))
require.Equal(t, "develop", fetched.Repositories[0].BaseBranch)
}
func TestHTTPRepositorySetUpdateCarriesMemberBaseBranch(t *testing.T) {
router, _ := newRepositorySetTestRouter(t)
created := createSetViaHTTP(t, router, map[string]any{
"name": "Base branches",
"repository_ids": []string{"repo-web"},
})
recorder := doJSON(t, router, http.MethodPatch, "/api/v1/repository-sets/"+created.ID, map[string]any{
"repositories": []map[string]any{
{"repository_id": "repo-gateway", "base_branch": "release"},
},
})
require.Equal(t, http.StatusOK, recorder.Code, recorder.Body.String())
var updated dto.RepositorySetDTO
require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &updated))
require.Len(t, updated.Repositories, 1)
require.Equal(t, "repo-gateway", updated.Repositories[0].RepositoryID)
require.Equal(t, "release", updated.Repositories[0].BaseBranch)
}
func TestHTTPRepositorySetRejectsConflictingMemberInputs(t *testing.T) {
router, _ := newRepositorySetTestRouter(t)
recorder := doJSON(t, router, http.MethodPost, "/api/v1/workspaces/ws-1/repository-sets", map[string]any{
"name": "Conflict",
"repository_ids": []string{"repo-web"},
"repositories": []map[string]any{{"repository_id": "repo-gateway"}},
})
require.Equal(t, http.StatusBadRequest, recorder.Code, recorder.Body.String())
}
@@ -3,6 +3,7 @@ package handlers
import (
"context"
"errors"
"fmt"
"net/http"
"github.com/gin-gonic/gin"
@@ -76,19 +77,39 @@ func repositorySetsToListResponse(sets []*models.RepositorySet) dto.ListReposito
// repositorySetCreateBody is the create payload. workspace_id is read from the
// path on HTTP and from the payload over WebSocket.
type repositorySetCreateBody struct {
WorkspaceID string `json:"workspace_id"`
Name string `json:"name"`
Description string `json:"description"`
RepositoryIDs []string `json:"repository_ids"`
WorkspaceID string `json:"workspace_id"`
Name string `json:"name"`
Description string `json:"description"`
RepositoryIDs *[]string `json:"repository_ids"`
Repositories *[]service.RepositorySetMemberInput `json:"repositories"`
}
func (b repositorySetCreateBody) serviceRequest(workspaceID string) (*service.CreateRepositorySetRequest, error) {
if b.RepositoryIDs != nil && b.Repositories != nil {
return nil, fmt.Errorf("%w: repositories and repository_ids cannot both be provided", service.ErrInvalidRepositorySet)
}
req := &service.CreateRepositorySetRequest{
WorkspaceID: workspaceID,
Name: b.Name,
Description: b.Description,
}
if b.RepositoryIDs != nil {
req.RepositoryIDs = *b.RepositoryIDs
}
if b.Repositories != nil {
req.Repositories = *b.Repositories
}
return req, nil
}
// repositorySetUpdateBody patches a set. Pointers distinguish "absent" from
// "set to empty": an absent repository_ids leaves membership alone, while an
// explicitly empty one is a rejected request rather than a silent wipe.
type repositorySetUpdateBody struct {
Name *string `json:"name"`
Description *string `json:"description"`
RepositoryIDs *[]string `json:"repository_ids"`
Name *string `json:"name"`
Description *string `json:"description"`
RepositoryIDs *[]string `json:"repository_ids"`
Repositories *[]service.RepositorySetMemberInput `json:"repositories"`
}
// repositorySetStatus maps a service error to its HTTP status. Categories are
@@ -160,12 +181,12 @@ func (h *RepositorySetHandlers) httpCreateRepositorySet(c *gin.Context) {
if !h.resolveWritableWorkspace(c, workspaceID) {
return
}
set, err := h.service.CreateRepositorySet(c.Request.Context(), &service.CreateRepositorySetRequest{
WorkspaceID: workspaceID,
Name: body.Name,
Description: body.Description,
RepositoryIDs: body.RepositoryIDs,
})
request, err := body.serviceRequest(workspaceID)
if err != nil {
h.abortWithRepositorySetError(c, "create repository set", err)
return
}
set, err := h.service.CreateRepositorySet(c.Request.Context(), request)
if err != nil {
h.abortWithRepositorySetError(c, "create repository set", err)
return
@@ -200,6 +221,7 @@ func (h *RepositorySetHandlers) httpUpdateRepositorySet(c *gin.Context) {
Name: body.Name,
Description: body.Description,
RepositoryIDs: body.RepositoryIDs,
Repositories: body.Repositories,
})
if err != nil {
h.abortWithRepositorySetError(c, "update repository set", err)
@@ -300,12 +322,11 @@ func (h *RepositorySetHandlers) wsCreateRepositorySet(ctx context.Context, msg *
if errMsg, blocked := h.wsRejectReadOnlyWorkspace(ctx, msg, req.WorkspaceID); blocked {
return errMsg, nil
}
set, err := h.service.CreateRepositorySet(ctx, &service.CreateRepositorySetRequest{
WorkspaceID: req.WorkspaceID,
Name: req.Name,
Description: req.Description,
RepositoryIDs: req.RepositoryIDs,
})
request, err := req.serviceRequest(req.WorkspaceID)
if err != nil {
return h.wsRepositorySetError(msg, "create repository set", err)
}
set, err := h.service.CreateRepositorySet(ctx, request)
if err != nil {
return h.wsRepositorySetError(msg, "create repository set", err)
}
@@ -341,6 +362,7 @@ func (h *RepositorySetHandlers) wsUpdateRepositorySet(ctx context.Context, msg *
Name: req.Name,
Description: req.Description,
RepositoryIDs: req.RepositoryIDs,
Repositories: req.Repositories,
})
if err != nil {
return h.wsRepositorySetError(msg, "update repository set", err)
+3 -3
View File
@@ -2003,9 +2003,8 @@ type RepositoryBranchPolicy struct {
// RepositorySet is a named, reusable group of workspace repositories that fills
// the task-creation repository picker in one action.
//
// A set deliberately stores no branch. Branch choice belongs to a task and is
// already modelled on TaskRepository; a branch cached here would go stale
// against the repository's real refs.
// A set stores an optional base branch for each member. The task draft copies
// that preference when the set is applied; checkout state remains task-owned.
type RepositorySet struct {
ID string `json:"id"`
WorkspaceID string `json:"workspace_id"`
@@ -2024,6 +2023,7 @@ type RepositorySetItem struct {
RepositorySetID string `json:"repository_set_id"`
RepositoryID string `json:"repository_id"`
Position int `json:"position"`
BaseBranch string `json:"base_branch"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
@@ -493,10 +493,10 @@ type RepositorySetRepository interface {
// ListRepositorySetIDsByRepository reports which sets hold a repository, so a
// caller can publish their new shape after a deletion prunes membership.
ListRepositorySetIDsByRepository(ctx context.Context, repositoryID string) ([]string, error)
// UpdateRepositorySet writes the set's fields and, when repositoryIDs is
// UpdateRepositorySet writes the set's fields and, when repositoryItems is
// non-nil, replaces its whole membership in the same transaction so the two
// cannot land apart. A nil repositoryIDs leaves membership untouched.
UpdateRepositorySet(ctx context.Context, set *models.RepositorySet, repositoryIDs *[]string) error
// cannot land apart. A nil repositoryItems leaves membership untouched.
UpdateRepositorySet(ctx context.Context, set *models.RepositorySet, repositoryItems *[]models.RepositorySetItem) error
DeleteRepositorySet(ctx context.Context, id string) (bool, error)
}
@@ -65,6 +65,7 @@ func (r *Repository) runMigrations() error {
if err := r.ensureTeamAccessSchema(); err != nil {
return err
}
_ = r.migrate.Apply("repository_set_items.base_branch", `ALTER TABLE repository_set_items ADD COLUMN base_branch TEXT NOT NULL DEFAULT ''`)
if err := r.ensureRepositoryBranchPoliciesSchema(); err != nil {
return err
}
@@ -557,9 +557,9 @@ func (r *Repository) initTaskSchema() error {
// repositorySetsSchemaDDL declares the repository-set tables. It runs after
// initCoreSchema so `workspaces` and `repositories` exist for the foreign keys.
//
// Membership positions are contiguous from zero and carry no branch: branch
// choice belongs to a task (task_repositories), which is exactly what the user
// still decides after applying a set.
// Membership positions are contiguous from zero. A saved base branch is an
// optional task-form default; checkout and task branch choices remain task
// state.
const repositorySetsSchemaDDL = `
CREATE TABLE IF NOT EXISTS repository_sets (
id TEXT PRIMARY KEY,
@@ -577,6 +577,7 @@ const repositorySetsSchemaDDL = `
repository_set_id TEXT NOT NULL,
repository_id TEXT NOT NULL,
position INTEGER NOT NULL DEFAULT 0,
base_branch TEXT NOT NULL DEFAULT '',
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL,
FOREIGN KEY (repository_set_id) REFERENCES repository_sets(id) ON DELETE CASCADE,
@@ -82,9 +82,10 @@ func (r *Repository) insertRepositorySetItems(
items[i].UpdatedAt = now
_, err := tx.ExecContext(ctx, r.db.Rebind(`
INSERT INTO repository_set_items (
id, repository_set_id, repository_id, position, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?)
`), items[i].ID, setID, items[i].RepositoryID, items[i].Position, now, now)
id, repository_set_id, repository_id, position, base_branch, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?)
`), items[i].ID, setID, items[i].RepositoryID, items[i].Position,
items[i].BaseBranch, now, now)
if err != nil {
return err
}
@@ -190,7 +191,8 @@ func (r *Repository) listRepositorySetItems(
return itemsBySet, nil
}
query, args, err := sqlx.In(`
SELECT i.id, i.repository_set_id, i.repository_id, i.position, i.created_at, i.updated_at
SELECT i.id, i.repository_set_id, i.repository_id, i.position, i.base_branch,
i.created_at, i.updated_at
FROM repository_set_items i
INNER JOIN repository_sets s ON s.id = i.repository_set_id
INNER JOIN repositories rep ON rep.id = i.repository_id
@@ -211,7 +213,7 @@ func (r *Repository) listRepositorySetItems(
for rows.Next() {
item := models.RepositorySetItem{}
if err := rows.Scan(&item.ID, &item.RepositorySetID, &item.RepositoryID,
&item.Position, &item.CreatedAt, &item.UpdatedAt); err != nil {
&item.Position, &item.BaseBranch, &item.CreatedAt, &item.UpdatedAt); err != nil {
return nil, err
}
itemsBySet[item.RepositorySetID] = append(itemsBySet[item.RepositorySetID], item)
@@ -244,18 +246,18 @@ func (r *Repository) ListRepositorySetIDsByRepository(
return ids, rows.Err()
}
// UpdateRepositorySet writes the set's own fields and, when repositoryIDs is
// UpdateRepositorySet writes the set's own fields and, when repositoryItems is
// non-nil, replaces its whole membership in the SAME transaction.
//
// The two must commit together: a name change that lands while the membership
// replacement fails leaves the set renamed but still holding the old
// repositories, with the API reporting failure and publishing nothing. A nil
// repositoryIDs leaves membership untouched, which is how an update that only
// repositoryItems leaves membership untouched, which is how an update that only
// changes name or description is expressed.
func (r *Repository) UpdateRepositorySet(
ctx context.Context,
set *models.RepositorySet,
repositoryIDs *[]string,
repositoryItems *[]models.RepositorySetItem,
) error {
tx, err := r.db.BeginTxx(ctx, nil)
if err != nil {
@@ -280,8 +282,8 @@ func (r *Repository) UpdateRepositorySet(
return repoerrors.ErrRepositorySetNotFound
}
if repositoryIDs != nil {
if err := r.replaceItemsTx(ctx, tx, set.ID, *repositoryIDs, set.UpdatedAt); err != nil {
if repositoryItems != nil {
if err := r.replaceItemsTx(ctx, tx, set.ID, *repositoryItems, set.UpdatedAt); err != nil {
return err
}
}
@@ -295,18 +297,14 @@ func (r *Repository) replaceItemsTx(
ctx context.Context,
tx *sqlx.Tx,
setID string,
repositoryIDs []string,
repositoryItems []models.RepositorySetItem,
now time.Time,
) error {
if _, err := tx.ExecContext(ctx, r.db.Rebind(
`DELETE FROM repository_set_items WHERE repository_set_id = ?`), setID); err != nil {
return err
}
items := make([]models.RepositorySetItem, 0, len(repositoryIDs))
for _, repositoryID := range repositoryIDs {
items = append(items, models.RepositorySetItem{RepositoryID: repositoryID})
}
return r.insertRepositorySetItems(ctx, tx, setID, items, now)
return r.insertRepositorySetItems(ctx, tx, setID, repositoryItems, now)
}
// DeleteRepositorySet removes a set and, by cascade, its membership rows. It
@@ -0,0 +1,88 @@
package sqlite
import (
"context"
"testing"
"time"
"github.com/kandev/kandev/internal/task/models"
"github.com/kandev/kandev/internal/testutil"
)
func TestPostgresRepositorySetBaseBranchMigrationPreservesItems(t *testing.T) {
db := testutil.OpenIsolatedPostgres(t, testutil.PostgresDSNFromEnv(t))
repo, err := NewWithDB(db, db, nil)
if err != nil {
t.Fatalf("init postgres schema: %v", err)
}
ctx := context.Background()
if err := repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-pg-base", Name: "ws-pg-base"}); err != nil {
t.Fatalf("CreateWorkspace: %v", err)
}
if err := repo.CreateRepository(ctx, &models.Repository{
ID: "repo-pg-base", WorkspaceID: "ws-pg-base", Name: "repo-pg-base",
SourceType: "local", LocalPath: "/tmp/repo-pg-base",
}); err != nil {
t.Fatalf("CreateRepository: %v", err)
}
set := &models.RepositorySet{WorkspaceID: "ws-pg-base", Name: "Legacy base"}
if err := repo.CreateRepositorySet(ctx, set); err != nil {
t.Fatalf("CreateRepositorySet: %v", err)
}
if _, err := repo.db.Exec(`DROP TABLE repository_set_items`); err != nil {
t.Fatalf("drop current repository_set_items: %v", err)
}
if _, err := repo.db.Exec(`
CREATE TABLE repository_set_items (
id TEXT PRIMARY KEY,
repository_set_id TEXT NOT NULL,
repository_id TEXT NOT NULL,
position INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL,
FOREIGN KEY (repository_set_id) REFERENCES repository_sets(id) ON DELETE CASCADE,
FOREIGN KEY (repository_id) REFERENCES repositories(id) ON DELETE CASCADE,
UNIQUE(repository_set_id, repository_id)
)
`); err != nil {
t.Fatalf("create legacy repository_set_items: %v", err)
}
now := time.Now().UTC()
if _, err := repo.db.Exec(repo.db.Rebind(`
INSERT INTO repository_set_items
(id, repository_set_id, repository_id, position, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?)
`), "item-pg-base", set.ID, "repo-pg-base", 0, now, now); err != nil {
t.Fatalf("insert legacy repository-set item: %v", err)
}
if err := repo.runMigrations(); err != nil {
t.Fatalf("run base-branch migration: %v", err)
}
assertBaseBranchColumn := func(stage string) {
var count int
err := repo.db.Get(&count, repo.db.Rebind(`
SELECT COUNT(*)
FROM information_schema.columns
WHERE table_schema = current_schema() AND table_name = ? AND column_name = ?
`), "repository_set_items", "base_branch")
if err != nil {
t.Fatalf("inspect base_branch column after %s migration: %v", stage, err)
}
if count != 1 {
t.Fatalf("base_branch column count after %s migration = %d, want 1", stage, count)
}
}
assertBaseBranchColumn("first")
if err := repo.runMigrations(); err != nil {
t.Fatalf("replay base-branch migration: %v", err)
}
assertBaseBranchColumn("replayed")
loaded, err := repo.GetRepositorySet(ctx, set.ID)
if err != nil {
t.Fatalf("GetRepositorySet after migration: %v", err)
}
if len(loaded.Items) != 1 || loaded.Items[0].BaseBranch != "" {
t.Fatalf("migrated item = %+v, want one item with empty base", loaded.Items)
}
}
@@ -0,0 +1,121 @@
package sqlite
import (
"context"
"database/sql"
"testing"
"time"
"github.com/kandev/kandev/internal/task/models"
)
func TestRepositorySetItemsSchemaIncludesBaseBranch(t *testing.T) {
repo := newRepoForSetTests(t)
rows, err := repo.db.Queryx(`PRAGMA table_info(repository_set_items)`)
if err != nil {
t.Fatalf("inspect repository_set_items schema: %v", err)
}
defer func() { _ = rows.Close() }()
for rows.Next() {
var cid, notNull, primaryKey int
var name, columnType string
var defaultValue sql.NullString
if err := rows.Scan(&cid, &name, &columnType, &notNull, &defaultValue, &primaryKey); err != nil {
t.Fatalf("scan repository_set_items schema: %v", err)
}
if name == "base_branch" {
return
}
}
if err := rows.Err(); err != nil {
t.Fatalf("iterate repository_set_items schema: %v", err)
}
t.Fatal("repository_set_items is missing base_branch")
}
func TestRepositorySetRoundTripsSavedBaseBranch(t *testing.T) {
repo := newRepoForSetTests(t)
seedWorkspace(t, repo, "ws-base")
seedSetRepository(t, repo, "ws-base", "repo-base")
item := models.RepositorySetItem{RepositoryID: "repo-base", BaseBranch: "develop"}
set := &models.RepositorySet{
WorkspaceID: "ws-base",
Name: "Base branch",
Items: []models.RepositorySetItem{item},
}
if err := repo.CreateRepositorySet(context.Background(), set); err != nil {
t.Fatalf("CreateRepositorySet: %v", err)
}
loaded, err := repo.GetRepositorySet(context.Background(), set.ID)
if err != nil {
t.Fatalf("GetRepositorySet: %v", err)
}
got := loaded.Items[0].BaseBranch
if got != "develop" {
t.Fatalf("saved base branch = %q, want %q", got, "develop")
}
}
func TestRepositorySetBaseBranchMigrationPreservesLegacyItems(t *testing.T) {
repo := newRepoForSetTests(t)
ctx := context.Background()
seedWorkspace(t, repo, "ws-legacy-base")
seedSetRepository(t, repo, "ws-legacy-base", "repo-legacy-base")
set := &models.RepositorySet{
WorkspaceID: "ws-legacy-base",
Name: "Legacy base",
}
if err := repo.CreateRepositorySet(ctx, set); err != nil {
t.Fatalf("CreateRepositorySet: %v", err)
}
if _, err := repo.db.Exec(`DROP TABLE repository_set_items`); err != nil {
t.Fatalf("drop current repository_set_items: %v", err)
}
if _, err := repo.db.Exec(`
CREATE TABLE repository_set_items (
id TEXT PRIMARY KEY,
repository_set_id TEXT NOT NULL,
repository_id TEXT NOT NULL,
position INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL,
FOREIGN KEY (repository_set_id) REFERENCES repository_sets(id) ON DELETE CASCADE,
FOREIGN KEY (repository_id) REFERENCES repositories(id) ON DELETE CASCADE,
UNIQUE(repository_set_id, repository_id)
)
`); err != nil {
t.Fatalf("create legacy repository_set_items: %v", err)
}
now := time.Now().UTC()
if _, err := repo.db.Exec(repo.db.Rebind(`
INSERT INTO repository_set_items
(id, repository_set_id, repository_id, position, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?)
`), "legacy-item", set.ID, "repo-legacy-base", 0, now, now); err != nil {
t.Fatalf("insert legacy repository-set item: %v", err)
}
if err := repo.runMigrations(); err != nil {
t.Fatalf("run base-branch migration: %v", err)
}
if err := repo.runMigrations(); err != nil {
t.Fatalf("replay base-branch migration: %v", err)
}
loaded, err := repo.GetRepositorySet(ctx, set.ID)
if err != nil {
t.Fatalf("GetRepositorySet after migration: %v", err)
}
if len(loaded.Items) != 1 {
t.Fatalf("migrated items = %+v, want one item", loaded.Items)
}
if loaded.Items[0].RepositoryID != "repo-legacy-base" || loaded.Items[0].BaseBranch != "" {
t.Fatalf("migrated item = %+v, want legacy membership with empty base", loaded.Items[0])
}
}
@@ -58,7 +58,7 @@ func TestPostgresRepositorySetRoundTrip(t *testing.T) {
t.Fatalf("GetRepositorySetByName = %+v", byName)
}
pgMembers := []string{"repo-pg-web"}
pgMembers := []models.RepositorySetItem{{RepositoryID: "repo-pg-web"}}
if err := repo.UpdateRepositorySet(ctx, set, &pgMembers); err != nil {
t.Fatalf("UpdateRepositorySet: %v", err)
}
@@ -26,7 +26,10 @@ func TestUpdateRepositorySetRollsBackMetadataWhenMembershipFails(t *testing.T) {
renamed := *set
renamed.Name = "Renamed"
renamed.Description = "changed"
members := []string{"repo-web", "repo-does-not-exist"}
members := []models.RepositorySetItem{
{RepositoryID: "repo-web"},
{RepositoryID: "repo-does-not-exist"},
}
if err := repo.UpdateRepositorySet(ctx, &renamed, &members); err == nil {
t.Fatal("UpdateRepositorySet accepted a member that does not exist")
}
@@ -55,7 +58,7 @@ func TestUpdateRepositorySetAppliesBothHalvesTogether(t *testing.T) {
}
set.Name = "Backend"
members := []string{"repo-orders"}
members := []models.RepositorySetItem{{RepositoryID: "repo-orders"}}
if err := repo.UpdateRepositorySet(ctx, set, &members); err != nil {
t.Fatalf("UpdateRepositorySet: %v", err)
}
@@ -84,7 +87,7 @@ func TestUpdateRepositorySetOnDeletedSetReportsNotFound(t *testing.T) {
// An empty membership list touches no item rows, so without checking the
// metadata UPDATE's RowsAffected this used to commit successfully and let the
// service publish an update for a set that no longer exists.
empty := []string{}
empty := []models.RepositorySetItem{}
if err := repo.UpdateRepositorySet(ctx, set, &empty); !errors.Is(err, repoerrors.ErrRepositorySetNotFound) {
t.Fatalf("UpdateRepositorySet error = %v, want ErrRepositorySetNotFound", err)
}
@@ -208,7 +208,10 @@ func TestReplaceRepositorySetItemsRewritesOrderContiguously(t *testing.T) {
}
// Reorder and drop one member in a single replace.
members := []string{"repo-orders", "repo-web"}
members := []models.RepositorySetItem{
{RepositoryID: "repo-orders"},
{RepositoryID: "repo-web"},
}
if err := repo.UpdateRepositorySet(ctx, set, &members); err != nil {
t.Fatalf("UpdateRepositorySet: %v", err)
}
@@ -9,6 +9,7 @@ import (
"go.uber.org/zap"
"github.com/kandev/kandev/internal/common/securityutil"
"github.com/kandev/kandev/internal/events"
"github.com/kandev/kandev/internal/events/bus"
"github.com/kandev/kandev/internal/task/models"
@@ -35,22 +36,31 @@ var (
ErrUnknownRepositorySetMembers = errors.New("unknown repository set members")
)
// CreateRepositorySetRequest creates a set. RepositoryIDs is ordered and defines
// the apply order; it carries no branch, because branch choice belongs to a task.
// RepositorySetMemberInput is the wire/service input for one ordered set
// member. BaseBranch is optional; an empty value preserves task-form defaults.
type RepositorySetMemberInput struct {
RepositoryID string `json:"repository_id"`
BaseBranch string `json:"base_branch,omitempty"`
}
// CreateRepositorySetRequest creates a set. Repositories is the new ordered
// member shape. RepositoryIDs remains the no-base compatibility input.
type CreateRepositorySetRequest struct {
WorkspaceID string `json:"workspace_id"`
Name string `json:"name"`
Description string `json:"description"`
RepositoryIDs []string `json:"repository_ids"`
WorkspaceID string `json:"workspace_id"`
Name string `json:"name"`
Description string `json:"description"`
RepositoryIDs []string `json:"repository_ids"`
Repositories []RepositorySetMemberInput `json:"repositories"`
}
// UpdateRepositorySetRequest patches a set. Every field is optional; an absent
// field is left alone. A supplied RepositoryIDs replaces the whole membership
// list, which is also how reordering is expressed.
type UpdateRepositorySetRequest struct {
Name *string `json:"name"`
Description *string `json:"description"`
RepositoryIDs *[]string `json:"repository_ids"`
Name *string `json:"name"`
Description *string `json:"description"`
RepositoryIDs *[]string `json:"repository_ids"`
Repositories *[]RepositorySetMemberInput `json:"repositories"`
}
// CreateRepositorySet validates the whole request, then writes the set and its
@@ -69,14 +79,18 @@ func (s *Service) CreateRepositorySet(
if err := s.assertRepositorySetNameFree(ctx, req.WorkspaceID, name, ""); err != nil {
return nil, err
}
if err := s.validateRepositorySetMembers(ctx, req.WorkspaceID, req.RepositoryIDs); err != nil {
items, err := repositorySetItemsFromCreateRequest(req)
if err != nil {
return nil, err
}
if err := s.validateRepositorySetMembers(ctx, req.WorkspaceID, items); err != nil {
return nil, err
}
set := &models.RepositorySet{
WorkspaceID: req.WorkspaceID,
Name: name,
Description: strings.TrimSpace(req.Description),
Items: repositorySetItemsFor(req.RepositoryIDs),
Items: items,
}
if err := s.repositorySets.CreateRepositorySet(ctx, set); err != nil {
return nil, err
@@ -125,7 +139,11 @@ func (s *Service) UpdateRepositorySet(
// One call, one transaction: a rename that lands while the membership
// replacement fails would leave the set renamed but still holding the old
// repositories, with this method reporting failure and publishing nothing.
if err := s.repositorySets.UpdateRepositorySet(ctx, set, req.RepositoryIDs); err != nil {
var repositoryItems *[]models.RepositorySetItem
if req.Repositories != nil || req.RepositoryIDs != nil {
repositoryItems = &set.Items
}
if err := s.repositorySets.UpdateRepositorySet(ctx, set, repositoryItems); err != nil {
return nil, err
}
updated, err := s.repositorySets.GetRepositorySet(ctx, set.ID)
@@ -156,10 +174,22 @@ func (s *Service) prepareRepositorySetUpdate(
if req.Description != nil {
set.Description = strings.TrimSpace(*req.Description)
}
if req.RepositoryIDs != nil {
if err := s.validateRepositorySetMembers(ctx, set.WorkspaceID, *req.RepositoryIDs); err != nil {
if req.Repositories != nil && req.RepositoryIDs != nil {
return fmt.Errorf("%w: repositories and repository_ids cannot both be provided", ErrInvalidRepositorySet)
}
if req.Repositories != nil {
items := repositorySetItemsForInputs(*req.Repositories)
if err := s.validateRepositorySetMembers(ctx, set.WorkspaceID, items); err != nil {
return err
}
set.Items = items
}
if req.RepositoryIDs != nil {
items := repositorySetItemsFor(*req.RepositoryIDs)
if err := s.validateRepositorySetMembers(ctx, set.WorkspaceID, items); err != nil {
return err
}
set.Items = items
}
return nil
}
@@ -253,13 +283,14 @@ func (s *Service) assertRepositorySetNameFree(
func (s *Service) validateRepositorySetMembers(
ctx context.Context,
workspaceID string,
repositoryIDs []string,
items []models.RepositorySetItem,
) error {
if len(repositoryIDs) == 0 {
if len(items) == 0 {
return fmt.Errorf("%w: at least one repository is required", ErrInvalidRepositorySet)
}
seen := make(map[string]struct{}, len(repositoryIDs))
for _, id := range repositoryIDs {
seen := make(map[string]struct{}, len(items))
for _, item := range items {
id := item.RepositoryID
if strings.TrimSpace(id) == "" {
return fmt.Errorf("%w: repository ids must not be blank", ErrInvalidRepositorySet)
}
@@ -267,6 +298,9 @@ func (s *Service) validateRepositorySetMembers(
return fmt.Errorf("%w: repository %q listed more than once", ErrInvalidRepositorySet, id)
}
seen[id] = struct{}{}
if item.BaseBranch != "" && !securityutil.IsValidBaseBranchRef(item.BaseBranch) {
return fmt.Errorf("%w: unsafe base branch for repository %q", ErrInvalidRepositorySet, id)
}
}
// One workspace listing rather than a lookup per id, and it already excludes
// soft-deleted rows and other workspaces.
@@ -281,9 +315,9 @@ func (s *Service) validateRepositorySetMembers(
}
}
unknown := make([]string, 0)
for _, id := range repositoryIDs {
if _, ok := available[id]; !ok {
unknown = append(unknown, id)
for _, item := range items {
if _, ok := available[item.RepositoryID]; !ok {
unknown = append(unknown, item.RepositoryID)
}
}
if len(unknown) > 0 {
@@ -292,6 +326,27 @@ func (s *Service) validateRepositorySetMembers(
return nil
}
func repositorySetItemsFromCreateRequest(req *CreateRepositorySetRequest) ([]models.RepositorySetItem, error) {
if req.Repositories != nil && req.RepositoryIDs != nil {
return nil, fmt.Errorf("%w: repositories and repository_ids cannot both be provided", ErrInvalidRepositorySet)
}
if len(req.Repositories) > 0 {
return repositorySetItemsForInputs(req.Repositories), nil
}
return repositorySetItemsFor(req.RepositoryIDs), nil
}
func repositorySetItemsForInputs(inputs []RepositorySetMemberInput) []models.RepositorySetItem {
items := make([]models.RepositorySetItem, 0, len(inputs))
for _, input := range inputs {
items = append(items, models.RepositorySetItem{
RepositoryID: input.RepositoryID,
BaseBranch: input.BaseBranch,
})
}
return items
}
// repositorySetItemsFor turns an ordered id list into membership models. The
// store assigns positions from slice order.
func repositorySetItemsFor(repositoryIDs []string) []models.RepositorySetItem {
@@ -317,6 +372,7 @@ func (s *Service) publishRepositorySetEvent(
repositories = append(repositories, map[string]interface{}{
"repository_id": item.RepositoryID,
"position": item.Position,
"base_branch": item.BaseBranch,
})
}
data := map[string]interface{}{
@@ -0,0 +1,150 @@
package service
import (
"context"
"errors"
"testing"
)
func TestCreateRepositorySetPersistsMemberBasesAndPublishesThem(t *testing.T) {
svc, eventBus, repo := createTestService(t)
seedSetWorkspace(t, svc, repo)
set, err := svc.CreateRepositorySet(context.Background(), &CreateRepositorySetRequest{
WorkspaceID: "ws-1",
Name: "Branch set",
Repositories: []RepositorySetMemberInput{
{RepositoryID: "repo-web", BaseBranch: "develop"},
{RepositoryID: "repo-gateway"},
},
})
if err != nil {
t.Fatalf("CreateRepositorySet: %v", err)
}
if len(set.Items) != 2 || set.Items[0].BaseBranch != "develop" || set.Items[1].BaseBranch != "" {
t.Fatalf("created items = %+v", set.Items)
}
events := eventBus.GetPublishedEvents()
if len(events) != 1 {
t.Fatalf("published events = %#v, want one event", events)
}
data := events[0].Data.(map[string]interface{})
items := data["repositories"].([]map[string]interface{})
if items[0]["base_branch"] != "develop" || items[1]["base_branch"] != "" {
t.Fatalf("event members = %#v", items)
}
}
func TestRepositorySetRejectsUnsafeBaseWithoutWriting(t *testing.T) {
svc, _, repo := createTestService(t)
seedSetWorkspace(t, svc, repo)
_, err := svc.CreateRepositorySet(context.Background(), &CreateRepositorySetRequest{
WorkspaceID: "ws-1",
Name: "Unsafe",
Repositories: []RepositorySetMemberInput{
{RepositoryID: "repo-web", BaseBranch: "--upload-pack=evil"},
},
})
if !errors.Is(err, ErrInvalidRepositorySet) {
t.Fatalf("unsafe base error = %v, want ErrInvalidRepositorySet", err)
}
sets, err := svc.ListRepositorySets(context.Background(), "ws-1")
if err != nil {
t.Fatalf("ListRepositorySets: %v", err)
}
if len(sets) != 0 {
t.Fatalf("rejected set wrote %d rows", len(sets))
}
}
func TestRepositorySetRejectsMalformedBaseRefWithoutWriting(t *testing.T) {
for _, baseBranch := range []string{"main/", "a//b"} {
t.Run(baseBranch, func(t *testing.T) {
svc, _, repo := createTestService(t)
seedSetWorkspace(t, svc, repo)
_, err := svc.CreateRepositorySet(context.Background(), &CreateRepositorySetRequest{
WorkspaceID: "ws-1",
Name: "Malformed base",
Repositories: []RepositorySetMemberInput{
{RepositoryID: "repo-web", BaseBranch: baseBranch},
},
})
if !errors.Is(err, ErrInvalidRepositorySet) {
t.Fatalf("malformed base error = %v, want ErrInvalidRepositorySet", err)
}
})
}
}
func TestRepositorySetUpdateReplacesBasesAtomically(t *testing.T) {
svc, _, repo := createTestService(t)
seedSetWorkspace(t, svc, repo)
set := createFullStackSet(t, svc)
updated, err := svc.UpdateRepositorySet(context.Background(), set.ID, &UpdateRepositorySetRequest{
Repositories: &[]RepositorySetMemberInput{
{RepositoryID: "repo-orders", BaseBranch: "release"},
},
})
if err != nil {
t.Fatalf("UpdateRepositorySet: %v", err)
}
if len(updated.Items) != 1 || updated.Items[0].BaseBranch != "release" {
t.Fatalf("updated items = %+v", updated.Items)
}
bad := []RepositorySetMemberInput{{RepositoryID: "repo-web", BaseBranch: "bad branch"}}
_, err = svc.UpdateRepositorySet(context.Background(), set.ID, &UpdateRepositorySetRequest{
Name: stringPointer("Rejected"),
Repositories: &bad,
})
if !errors.Is(err, ErrInvalidRepositorySet) {
t.Fatalf("unsafe update error = %v, want ErrInvalidRepositorySet", err)
}
loaded, err := svc.GetRepositorySet(context.Background(), set.ID)
if err != nil {
t.Fatalf("GetRepositorySet: %v", err)
}
if loaded.Name != "Full-stack" || len(loaded.Items) != 1 || loaded.Items[0].BaseBranch != "release" {
t.Fatalf("rejected update changed set = %+v", loaded)
}
}
func TestRepositorySetRejectsConflictingMemberInputs(t *testing.T) {
svc, _, repo := createTestService(t)
seedSetWorkspace(t, svc, repo)
ids := []string{"repo-web"}
members := []RepositorySetMemberInput{{RepositoryID: "repo-gateway"}}
_, err := svc.CreateRepositorySet(context.Background(), &CreateRepositorySetRequest{
WorkspaceID: "ws-1",
Name: "Conflict",
RepositoryIDs: ids,
Repositories: members,
})
if !errors.Is(err, ErrInvalidRepositorySet) {
t.Fatalf("conflicting create error = %v, want ErrInvalidRepositorySet", err)
}
_, err = svc.CreateRepositorySet(context.Background(), &CreateRepositorySetRequest{
WorkspaceID: "ws-1",
Name: "Conflict empty members",
RepositoryIDs: ids,
Repositories: []RepositorySetMemberInput{},
})
if !errors.Is(err, ErrInvalidRepositorySet) {
t.Fatalf("conflicting create with empty members error = %v, want ErrInvalidRepositorySet", err)
}
set := createFullStackSet(t, svc)
_, err = svc.UpdateRepositorySet(context.Background(), set.ID, &UpdateRepositorySetRequest{
RepositoryIDs: &ids,
Repositories: &members,
})
if !errors.Is(err, ErrInvalidRepositorySet) {
t.Fatalf("conflicting update error = %v, want ErrInvalidRepositorySet", err)
}
}
@@ -11,14 +11,21 @@ import type { RepositorySet } from "@/lib/types/http";
/**
* An in-progress set edit. `setId` is null while creating.
*
* `repositoryIds` is ordered, and that order is what applying the set will use,
* so the editor's selection order is meaningful rather than incidental.
* `members` is ordered, and that order is what applying the set will use, so
* the editor's order is meaningful rather than incidental. A member's base is
* optional; an empty value means that task creation should use its normal
* default for that repository.
*/
export type RepositorySetDraftMember = {
repositoryId: string;
baseBranch: string;
};
export type RepositorySetDraft = {
setId: string | null;
name: string;
description: string;
repositoryIds: string[];
members: RepositorySetDraftMember[];
};
/**
@@ -39,7 +46,7 @@ export function useWorkspaceRepositorySets({ workspaceId }: { workspaceId: strin
const startCreate = useCallback(() => {
setError(null);
setDraft({ setId: null, name: "", description: "", repositoryIds: [] });
setDraft({ setId: null, name: "", description: "", members: [] });
}, []);
const startEdit = useCallback((set: RepositorySet) => {
@@ -48,7 +55,10 @@ export function useWorkspaceRepositorySets({ workspaceId }: { workspaceId: strin
setId: set.id,
name: set.name,
description: set.description,
repositoryIds: set.repositories.map((member) => member.repository_id as string),
members: set.repositories.map((member) => ({
repositoryId: member.repository_id as string,
baseBranch: member.base_branch ?? "",
})),
});
}, []);
@@ -64,22 +74,26 @@ export function useWorkspaceRepositorySets({ workspaceId }: { workspaceId: strin
const submitDraft = useCallback(async () => {
if (!draft || saving) return;
const name = draft.name.trim();
if (!name || draft.repositoryIds.length === 0) return;
if (!name || draft.members.length === 0) return;
setSaving(true);
setError(null);
try {
// A supplied repository_ids replaces the whole membership list, which is
// also how the editor expresses a reorder.
// A supplied repositories list replaces the whole membership list, which
// is also how the editor expresses a reorder and base change.
const repositories = draft.members.map((member) => ({
repositoryId: member.repositoryId,
baseBranch: member.baseBranch,
}));
const saved = draft.setId
? await updateRepositorySet(draft.setId, {
name,
description: draft.description.trim(),
repositoryIds: draft.repositoryIds,
repositories,
})
: await createRepositorySet(workspaceId, {
name,
description: draft.description.trim(),
repositoryIds: draft.repositoryIds,
repositories,
});
upsertRepositorySet(workspaceId, saved);
setDraft(null);
@@ -0,0 +1,352 @@
"use client";
import { useMemo, useState } from "react";
import { IconArrowDown, IconArrowUp, IconGitBranch, IconRefresh, IconX } from "@tabler/icons-react";
import { Button } from "@kandev/ui/button";
import { Input } from "@kandev/ui/input";
import { controlSizingClassName } from "@kandev/ui/control-sizing";
import { useTranslation } from "react-i18next";
import { Pill } from "@/components/task-create-dialog-pill";
import { buildBaseBranchOptions } from "@/components/task-create-dialog-repo-base-branch";
import { Combobox, type ComboboxOption } from "@/components/combobox";
import { useBranches } from "@/hooks/domains/workspace/use-repository-branches";
import { scoreBranch } from "@/lib/utils/branch-filter";
import type { Repository } from "@/lib/types/http";
import type { RepositorySetDraft, RepositorySetDraftMember } from "./use-workspace-repository-sets";
type RepositorySetMembersFieldProps = {
workspaceId: string;
draft: RepositorySetDraft;
repositories: Repository[];
onChange: (patch: Partial<RepositorySetDraft>) => void;
};
function useRepositorySetMemberActions({
draft,
repositories,
onChange,
}: Omit<RepositorySetMembersFieldProps, "workspaceId">) {
const [memberSearch, setMemberSearch] = useState("");
const selectedIds = useMemo(
() => new Set(draft.members.map((member) => member.repositoryId)),
[draft.members],
);
const availableRepositories = useMemo(
() => repositories.filter((repository) => !selectedIds.has(repository.id as string)),
[repositories, selectedIds],
);
const visibleMembers = useMemo(() => {
const query = memberSearch.trim().toLowerCase();
return draft.members
.map((member, index) => ({ member, index }))
.filter(({ member }) => {
if (!query) return true;
const repository = repositories.find((candidate) => candidate.id === member.repositoryId);
return [repository?.name, repository?.local_path, member.baseBranch]
.filter(Boolean)
.some((value) => value?.toLowerCase().includes(query));
});
}, [draft.members, memberSearch, repositories]);
const addRepository = (repositoryId: string) => {
if (!repositoryId || selectedIds.has(repositoryId)) return;
onChange({ members: [...draft.members, { repositoryId, baseBranch: "" }] });
};
const updateMember = (index: number, patch: Partial<RepositorySetDraftMember>) => {
onChange({
members: draft.members.map((member, memberIndex) =>
memberIndex === index ? { ...member, ...patch } : member,
),
});
};
const removeMember = (index: number) => {
onChange({ members: draft.members.filter((_, memberIndex) => memberIndex !== index) });
};
const moveMember = (index: number, delta: number) => {
const target = index + delta;
if (target < 0 || target >= draft.members.length) return;
const members = [...draft.members];
[members[index], members[target]] = [members[target], members[index]];
onChange({ members });
};
const resetBases = () => {
onChange({ members: draft.members.map((member) => ({ ...member, baseBranch: "" })) });
};
return {
memberSearch,
setMemberSearch,
availableRepositories,
visibleMembers,
addRepository,
updateMember,
removeMember,
moveMember,
resetBases,
hasSavedBases: draft.members.some((member) => member.baseBranch !== ""),
};
}
export function RepositorySetMembersField({
workspaceId,
draft,
repositories,
onChange,
}: RepositorySetMembersFieldProps) {
const { t } = useTranslation();
const {
memberSearch,
setMemberSearch,
availableRepositories,
visibleMembers,
addRepository,
updateMember,
removeMember,
moveMember,
resetBases,
hasSavedBases,
} = useRepositorySetMemberActions({ draft, repositories, onChange });
return (
<div className="space-y-2">
<div className="flex flex-col gap-2 md:flex-row md:items-end md:justify-between">
<div className="min-w-0 flex-1">
<p className="text-xs font-medium">{t("workspaces:repositorySetsMembersLabel")}</p>
<p className="text-xs text-muted-foreground">
{t("workspaces:repositorySetsMembersHint")}
</p>
</div>
<Combobox
options={availableRepositories.map(repositoryOption)}
value=""
onValueChange={addRepository}
placeholder={t("workspaces:repositorySetsAddRepository")}
searchPlaceholder={t("workspaces:repositorySetsSearchRepositories")}
emptyMessage={t("workspaces:repositorySetsNoMatchingRepositories")}
disabled={availableRepositories.length === 0}
triggerClassName="border border-input bg-background px-2 md:w-48 md:shrink-0"
popoverPortal
popoverAlign="end"
testId="repository-set-add-repository"
dropdownTestId="repository-set-add-repository-dropdown"
ariaLabel={t("workspaces:repositorySetsAddRepository")}
/>
</div>
{draft.members.length > 0 ? (
<div className="space-y-2">
<Input
value={memberSearch}
onChange={(event) => setMemberSearch(event.target.value)}
placeholder={t("workspaces:repositorySetsSearchSelected")}
aria-label={t("workspaces:repositorySetsSearchSelected")}
data-testid="repository-set-member-search"
/>
<div className="space-y-2" data-testid="repository-set-members">
{visibleMembers.length === 0 ? (
<p className="rounded-md border px-3 py-3 text-xs text-muted-foreground">
{t("workspaces:repositorySetsNoMatchingMembers")}
</p>
) : (
visibleMembers.map(({ member, index }) => (
<RepositorySetMemberRow
key={member.repositoryId}
workspaceId={workspaceId}
member={member}
repository={repositories.find(
(candidate) => candidate.id === member.repositoryId,
)}
canMoveUp={!memberSearch && index > 0}
canMoveDown={!memberSearch && index < draft.members.length - 1}
onBaseBranchChange={(baseBranch) => updateMember(index, { baseBranch })}
onMoveUp={() => moveMember(index, -1)}
onMoveDown={() => moveMember(index, 1)}
onRemove={() => removeMember(index)}
/>
))
)}
</div>
</div>
) : (
<p className="rounded-md border border-dashed px-3 py-4 text-xs text-muted-foreground">
{t("workspaces:repositorySetsNoMembersSelected")}
</p>
)}
<Button
type="button"
variant="outline"
className="cursor-pointer"
disabled={!hasSavedBases}
onClick={resetBases}
data-testid="repository-set-reset-bases"
>
<IconRefresh className="mr-1.5 h-4 w-4" />
{t("workspaces:repositorySetsResetBases")}
</Button>
</div>
);
}
function repositoryOption(repository: Repository): ComboboxOption {
const label = repository.name || repository.provider_name || (repository.id as string);
const description = repository.local_path || repository.remote_url || "";
return {
value: repository.id as string,
label,
description,
keywords: [label, description, repository.provider_owner, repository.provider_name].filter(
Boolean,
),
renderLabel: () => (
<span className="flex min-w-0 flex-col">
<span className="truncate">{label}</span>
{description ? (
<span className="truncate text-xs text-muted-foreground">{description}</span>
) : null}
</span>
),
};
}
type RepositorySetMemberRowProps = {
workspaceId: string;
member: RepositorySetDraftMember;
repository?: Repository;
canMoveUp: boolean;
canMoveDown: boolean;
onBaseBranchChange: (value: string) => void;
onMoveUp: () => void;
onMoveDown: () => void;
onRemove: () => void;
};
function RepositorySetMemberRow({
workspaceId,
member,
repository,
canMoveUp,
canMoveDown,
onBaseBranchChange,
onMoveUp,
onMoveDown,
onRemove,
}: RepositorySetMemberRowProps) {
const { t } = useTranslation();
return (
<div
className="grid gap-2 rounded-md border p-2 md:grid-cols-[minmax(0,1fr)_minmax(0,12rem)_auto] md:items-center"
data-testid={`repository-set-member-${member.repositoryId}`}
>
<div className="min-w-0">
<p className="truncate text-sm font-medium">{repository?.name ?? member.repositoryId}</p>
{repository?.local_path ? (
<p className="truncate text-xs text-muted-foreground" title={repository.local_path}>
{repository.local_path}
</p>
) : null}
</div>
{repository ? (
<RepositorySetBaseBranchPicker
workspaceId={workspaceId}
member={member}
repository={repository}
onChange={onBaseBranchChange}
/>
) : (
<p className="text-xs text-destructive">{t("workspaces:repositorySetsMemberMissing")}</p>
)}
<div className="flex justify-end gap-0.5">
<Button
type="button"
size="icon"
variant="ghost"
className="cursor-pointer"
disabled={!canMoveUp}
aria-label={t("workspaces:repositorySetsMoveUp")}
onClick={onMoveUp}
data-testid={`repository-set-move-up-${member.repositoryId}`}
>
<IconArrowUp className="h-4 w-4" />
</Button>
<Button
type="button"
size="icon"
variant="ghost"
className="cursor-pointer"
disabled={!canMoveDown}
aria-label={t("workspaces:repositorySetsMoveDown")}
onClick={onMoveDown}
data-testid={`repository-set-move-down-${member.repositoryId}`}
>
<IconArrowDown className="h-4 w-4" />
</Button>
<Button
type="button"
size="icon"
variant="ghost"
className="cursor-pointer"
aria-label={t("workspaces:repositorySetsRemove")}
onClick={onRemove}
data-testid={`repository-set-remove-${member.repositoryId}`}
>
<IconX className="h-4 w-4" />
</Button>
</div>
</div>
);
}
function RepositorySetBaseBranchPicker({
workspaceId,
member,
repository,
onChange,
}: {
workspaceId: string;
member: RepositorySetDraftMember;
repository: Repository;
onChange: (value: string) => void;
}) {
const { t } = useTranslation();
const [branchOpen, setBranchOpen] = useState(false);
const { branches, isLoaded, isLoading, refresh } = useBranches(
{ kind: "id", workspaceId, repositoryId: repository.id as string },
branchOpen,
);
const options = useMemo(() => {
return buildBaseBranchOptions({
branches,
branchesLoaded: isLoaded,
savedBaseBranch: member.baseBranch,
defaultBranch: repository.default_branch ?? "",
});
}, [branches, isLoaded, member.baseBranch, repository.default_branch]);
const taskDefaultLabel =
options.find((option) => option.value === "")?.label ??
t("workspaces:repositorySetsTaskDefaultNoBranch");
return (
<Pill
icon={<IconGitBranch className="h-4 w-4 shrink-0 text-muted-foreground" />}
value={member.baseBranch || taskDefaultLabel}
selectedValue={member.baseBranch}
options={options}
onSelect={onChange}
disabled={false}
placeholder={taskDefaultLabel}
searchPlaceholder={t("task:searchBranches")}
emptyMessage={t("task:noBranches")}
onRefresh={refresh ? () => void refresh() : undefined}
refreshing={isLoading}
ariaLabel={t("workspaces:repositorySetsBaseBranchFor", { name: repository.name })}
testId={`repository-set-base-${member.repositoryId}`}
dropdownTestId={`repository-set-base-dropdown-${member.repositoryId}`}
triggerClassName={controlSizingClassName(
"standard",
"min-w-0 w-full border border-input bg-background px-2 hover:bg-background",
)}
onOpenChange={setBranchOpen}
filter={scoreBranch}
/>
);
}
@@ -0,0 +1,183 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { TooltipProvider } from "@kandev/ui/tooltip";
import type { Repository } from "@/lib/types/http";
import { repositoryId, workspaceId } from "@/lib/types/ids";
import type { RepositorySetDraft } from "./use-workspace-repository-sets";
const mockUseBranches = vi.fn();
const mockRefresh = vi.fn();
vi.mock("@/hooks/use-responsive-breakpoint", () => ({
useResponsiveBreakpoint: () => ({ isMobile: false }),
}));
vi.mock("@/hooks/domains/workspace/use-repository-branches", () => ({
useBranches: (...args: unknown[]) => mockUseBranches(...args),
}));
import { RepositorySetEditorDialog } from "./workspace-repository-set-editor";
const repository: Repository = {
id: repositoryId("repo-web"),
workspace_id: workspaceId("ws-1"),
name: "web",
source_type: "local",
local_path: "/repos/web",
provider: "",
provider_repo_id: "",
provider_owner: "",
provider_name: "",
default_branch: "main",
worktree_branch_prefix: "feature/",
pull_before_worktree: false,
setup_script: "",
cleanup_script: "",
dev_script: "",
copy_files: "",
created_at: "2026-08-17T09:00:00Z",
updated_at: "2026-08-17T09:00:00Z",
};
const repositoryWithoutDefaultBranch: Repository = { ...repository, default_branch: "" };
const BASE_BRANCH_TEST_ID = "repository-set-base-repo-web";
function draft(baseBranch = ""): RepositorySetDraft {
return {
setId: "set-1",
name: "Full-stack",
description: "",
members: [{ repositoryId: "repo-web", baseBranch }],
};
}
function renderEditor(
currentDraft = draft(),
onChange = vi.fn(),
currentRepository: Repository = repository,
) {
return render(
<TooltipProvider>
<RepositorySetEditorDialog
workspaceId="ws-1"
draft={currentDraft}
repositories={[currentRepository]}
error={null}
saving={false}
onClose={vi.fn()}
onChange={onChange}
onSubmit={vi.fn()}
/>
</TooltipProvider>,
);
}
beforeEach(() => {
mockUseBranches.mockReset();
mockRefresh.mockReset();
mockUseBranches.mockImplementation((_source: unknown, enabled: boolean) => ({
branches: enabled
? [
{ name: "main", type: "local", remote: "" },
{ name: "develop", type: "local", remote: "" },
{ name: "main", type: "remote", remote: "origin" },
]
: [],
isLoaded: enabled,
isLoading: false,
refresh: mockRefresh,
}));
});
afterEach(() => cleanup());
describe("RepositorySetEditorDialog base branch picker", () => {
it("does not load branches until a member picker opens", () => {
renderEditor();
expect(mockUseBranches).toHaveBeenCalledWith(
{ kind: "id", workspaceId: "ws-1", repositoryId: "repo-web" },
false,
);
expect(mockUseBranches).not.toHaveBeenCalledWith(
{ kind: "id", workspaceId: "ws-1", repositoryId: "repo-web" },
true,
);
fireEvent.click(screen.getByTestId(BASE_BRANCH_TEST_ID));
expect(mockUseBranches).toHaveBeenCalledWith(
{ kind: "id", workspaceId: "ws-1", repositoryId: "repo-web" },
true,
);
});
it("keeps a saved branch visible before the lazy branch request settles", () => {
renderEditor(draft("retired"));
expect(screen.getByTestId(BASE_BRANCH_TEST_ID).textContent).toContain("retired");
});
it("keeps an unavailable saved branch visible and disabled after loading", () => {
renderEditor(draft("retired"));
fireEvent.click(screen.getByTestId(BASE_BRANCH_TEST_ID));
const unavailable = screen
.getAllByRole("option")
.find((option) => option.textContent?.includes("retired"));
expect(unavailable).toBeDefined();
expect(unavailable?.getAttribute("aria-disabled")).toBe("true");
expect(screen.getByTestId(BASE_BRANCH_TEST_ID).textContent).toContain("retired");
});
it("shares the New Task grouped branch picker search, origin labels, and refresh", () => {
renderEditor();
fireEvent.click(screen.getByTestId(BASE_BRANCH_TEST_ID));
expect(screen.getByPlaceholderText("Search branches...")).toBeTruthy();
expect(screen.getByText("Branches")).toBeTruthy();
expect(screen.getByText("origin/main")).toBeTruthy();
expect(screen.getByText("origin")).toBeTruthy();
expect(screen.getAllByText("local").length).toBeGreaterThan(0);
fireEvent.change(screen.getByPlaceholderText("Search branches..."), {
target: { value: "origin" },
});
expect(screen.getByText("origin/main")).toBeTruthy();
expect(screen.queryByText("develop")).toBeNull();
fireEvent.click(screen.getByTestId("branch-refresh-button"));
expect(mockRefresh).toHaveBeenCalledOnce();
});
it("uses a no-branch Task default label when the repository has no default", () => {
renderEditor(draft(), vi.fn(), repositoryWithoutDefaultBranch);
expect(screen.getByTestId(BASE_BRANCH_TEST_ID).textContent).toBe("Task default");
fireEvent.click(screen.getByTestId(BASE_BRANCH_TEST_ID));
expect(screen.getByRole("option", { name: "Task default" })).toBeTruthy();
});
it("disables reordering while the member list is filtered", () => {
renderEditor({
...draft(),
members: [
{ repositoryId: "repo-web", baseBranch: "" },
{ repositoryId: "repo-missing", baseBranch: "retired" },
],
});
fireEvent.change(screen.getByTestId("repository-set-member-search"), {
target: { value: "retired" },
});
expect(
(screen.getByTestId("repository-set-move-up-repo-missing") as HTMLButtonElement).disabled,
).toBe(true);
expect(
(screen.getByTestId("repository-set-move-down-repo-missing") as HTMLButtonElement).disabled,
).toBe(true);
});
});
@@ -1,8 +1,7 @@
"use client";
import { IconArrowDown, IconArrowUp } from "@tabler/icons-react";
import { useState } from "react";
import { Button } from "@kandev/ui/button";
import { Checkbox } from "@kandev/ui/checkbox";
import {
Dialog,
DialogContent,
@@ -11,14 +10,26 @@ import {
DialogHeader,
DialogTitle,
} from "@kandev/ui/dialog";
import {
Drawer,
DrawerContent,
DrawerDescription,
DrawerFooter,
DrawerHeader,
DrawerTitle,
} from "@kandev/ui/drawer";
import { Input } from "@kandev/ui/input";
import { Textarea } from "@kandev/ui/textarea";
import { useTranslation } from "react-i18next";
import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint";
import { TaskCreateDialogPopoverContainerProvider } from "@/hooks/use-task-create-dialog-popover-container";
import type { Repository } from "@/lib/types/http";
import { RepositorySetMembersField } from "./workspace-repository-set-editor-members";
import type { RepositorySetDraft } from "./use-workspace-repository-sets";
type RepositorySetEditorDialogProps = {
workspaceId: string;
draft: RepositorySetDraft | null;
repositories: Repository[];
error: string | null;
@@ -28,12 +39,74 @@ type RepositorySetEditorDialogProps = {
onSubmit: () => void;
};
type RepositorySetEditorFooterProps = {
formId: string;
canSave: boolean;
isMobile: boolean;
onClose: () => void;
};
function RepositorySetEditorFooter({
formId,
canSave,
isMobile,
onClose,
}: RepositorySetEditorFooterProps) {
const { t } = useTranslation();
if (isMobile) {
return (
<DrawerFooter className="shrink-0 border-t px-4 pt-3 pb-[max(1rem,env(safe-area-inset-bottom))]">
<Button
type="submit"
form={formId}
disabled={!canSave}
className="min-h-11 cursor-pointer"
data-testid="repository-set-editor-save"
>
{t("workspaces:repositorySetsSave")}
</Button>
<Button
type="button"
variant="outline"
onClick={onClose}
className="min-h-11 cursor-pointer"
data-testid="repository-set-editor-cancel"
>
{t("common:cancel")}
</Button>
</DrawerFooter>
);
}
return (
<DialogFooter className="shrink-0 px-4 pb-4 pt-2">
<Button
type="button"
variant="outline"
onClick={onClose}
className="cursor-pointer"
data-testid="repository-set-editor-cancel"
>
{t("common:cancel")}
</Button>
<Button
type="submit"
form={formId}
disabled={!canSave}
className="cursor-pointer"
data-testid="repository-set-editor-save"
>
{t("workspaces:repositorySetsSave")}
</Button>
</DialogFooter>
);
}
/**
* Creates or edits one set: name, description, and which repositories belong in
* which order. Member selection offers only this workspace's live repositories,
* so a set can never be given a member the backend would reject.
* Creates or edits one set. The desktop dialog and phone drawer share the same
* form body, so member order and saved-base behavior do not diverge by device.
*/
export function RepositorySetEditorDialog({
workspaceId,
draft,
repositories,
error,
@@ -43,13 +116,65 @@ export function RepositorySetEditorDialog({
onSubmit,
}: RepositorySetEditorDialogProps) {
const { t } = useTranslation();
const { isMobile } = useResponsiveBreakpoint();
const [popoverContainer, setPopoverContainer] = useState<HTMLDivElement | null>(null);
if (!draft) return null;
const canSave = draft.name.trim().length > 0 && draft.repositoryIds.length > 0 && !saving;
const formId = "repository-set-editor-form";
const canSave = draft.name.trim().length > 0 && draft.members.length > 0 && !saving;
const body = (
<TaskCreateDialogPopoverContainerProvider container={popoverContainer}>
<RepositorySetEditorBody
workspaceId={workspaceId}
draft={draft}
repositories={repositories}
error={error}
formId={formId}
onChange={onChange}
onSubmit={onSubmit}
/>
</TaskCreateDialogPopoverContainerProvider>
);
const footer = (
<RepositorySetEditorFooter
formId={formId}
canSave={canSave}
isMobile={isMobile}
onClose={onClose}
/>
);
if (isMobile) {
return (
<Drawer open onOpenChange={(open) => !open && onClose()}>
<DrawerContent
ref={setPopoverContainer}
className="flex h-[100dvh] max-h-[100dvh] flex-col overflow-visible data-[vaul-drawer-direction=bottom]:mt-0 data-[vaul-drawer-direction=bottom]:max-h-[100dvh]"
data-testid="repository-set-editor-surface"
>
<DrawerHeader className="shrink-0 px-4 py-3 text-left">
<DrawerTitle>
{draft.setId
? t("workspaces:repositorySetsEditTitle")
: t("workspaces:repositorySetsCreateTitle")}
</DrawerTitle>
<DrawerDescription>{t("workspaces:repositorySetsEditorDescription")}</DrawerDescription>
</DrawerHeader>
{body}
{footer}
</DrawerContent>
</Drawer>
);
}
return (
<Dialog open onOpenChange={(next) => (next ? undefined : onClose())}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<Dialog open onOpenChange={(open) => !open && onClose()}>
<DialogContent
ref={setPopoverContainer}
className="flex max-h-[92dvh] flex-col gap-0 overflow-visible p-0 sm:max-w-2xl"
data-testid="repository-set-editor-surface"
>
<DialogHeader className="shrink-0 px-4 pb-1 pt-3 text-left">
<DialogTitle>
{draft.setId
? t("workspaces:repositorySetsEditTitle")
@@ -57,173 +182,84 @@ export function RepositorySetEditorDialog({
</DialogTitle>
<DialogDescription>{t("workspaces:repositorySetsEditorDescription")}</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<label className="block space-y-1.5 text-xs font-medium">
<span>{t("workspaces:repositorySetsNameLabel")}</span>
<Input
value={draft.name}
maxLength={100}
autoFocus
onChange={(event) => onChange({ name: event.target.value })}
data-testid="repository-set-editor-name"
/>
</label>
<label className="block space-y-1.5 text-xs font-medium">
<span>{t("workspaces:repositorySetsDescriptionLabel")}</span>
<Textarea
value={draft.description}
rows={2}
onChange={(event) => onChange({ description: event.target.value })}
data-testid="repository-set-editor-description"
/>
</label>
<MemberPicker
repositories={repositories}
selectedIds={draft.repositoryIds}
onChange={(repositoryIds) => onChange({ repositoryIds })}
/>
{error ? (
<p className="text-xs text-destructive" data-testid="repository-set-editor-error">
{error}
</p>
) : null}
</div>
<DialogFooter>
<Button
type="button"
className="cursor-pointer"
disabled={!canSave}
onClick={onSubmit}
data-testid="repository-set-editor-save"
>
{t("workspaces:repositorySetsSave")}
</Button>
</DialogFooter>
{body}
{footer}
</DialogContent>
</Dialog>
);
}
type MemberPickerProps = {
type RepositorySetEditorBodyProps = {
workspaceId: string;
draft: RepositorySetDraft;
repositories: Repository[];
selectedIds: string[];
onChange: (repositoryIds: string[]) => void;
error: string | null;
formId: string;
onChange: (patch: Partial<RepositorySetDraft>) => void;
onSubmit: () => void;
};
/**
* Which repositories are in the set, and in what order. Selected members are
* listed first in their own order so reordering is visible; the rest follow.
*/
function MemberPicker({ repositories, selectedIds, onChange }: MemberPickerProps) {
function RepositorySetEditorBody({
workspaceId,
draft,
repositories,
error,
formId,
onChange,
onSubmit,
}: RepositorySetEditorBodyProps) {
const { t } = useTranslation();
const byId = new Map(repositories.map((repository) => [repository.id as string, repository]));
const selected = selectedIds.filter((id) => byId.has(id));
const unselected = repositories
.map((repository) => repository.id as string)
.filter((id) => !selected.includes(id));
const toggle = (id: string) => {
onChange(selected.includes(id) ? selected.filter((entry) => entry !== id) : [...selected, id]);
};
const move = (id: string, delta: number) => {
const index = selected.indexOf(id);
const target = index + delta;
if (index === -1 || target < 0 || target >= selected.length) return;
const next = [...selected];
[next[index], next[target]] = [next[target], next[index]];
onChange(next);
};
return (
<div className="space-y-1.5">
<p className="text-xs font-medium">{t("workspaces:repositorySetsMembersLabel")}</p>
<p className="text-xs text-muted-foreground">{t("workspaces:repositorySetsMembersHint")}</p>
<div className="max-h-64 space-y-1 overflow-y-auto rounded-md border p-2">
{repositories.length === 0 ? (
<p className="text-xs text-muted-foreground">
{t("workspaces:repositorySetsNoRepositories")}
</p>
) : (
[...selected, ...unselected].map((id) => (
<MemberRow
key={id}
label={byId.get(id)?.name ?? id}
repositoryId={id}
checked={selected.includes(id)}
canMoveUp={selected.indexOf(id) > 0}
canMoveDown={
selected.indexOf(id) !== -1 && selected.indexOf(id) < selected.length - 1
}
onToggle={() => toggle(id)}
onMoveUp={() => move(id, -1)}
onMoveDown={() => move(id, 1)}
/>
))
)}
</div>
</div>
);
}
type MemberRowProps = {
label: string;
repositoryId: string;
checked: boolean;
canMoveUp: boolean;
canMoveDown: boolean;
onToggle: () => void;
onMoveUp: () => void;
onMoveDown: () => void;
};
function MemberRow({
label,
repositoryId,
checked,
canMoveUp,
canMoveDown,
onToggle,
onMoveUp,
onMoveDown,
}: MemberRowProps) {
const { t } = useTranslation();
return (
<div className="flex items-center justify-between gap-2 rounded px-1 py-1">
{/* A 44px-tall label keeps the touch target usable on a phone. */}
<label className="flex min-h-11 min-w-0 flex-1 cursor-pointer items-center gap-2 text-sm">
<Checkbox
checked={checked}
onCheckedChange={onToggle}
data-testid={`repository-set-member-${repositoryId}`}
<form
id={formId}
data-testid="repository-set-editor-form"
onSubmit={(event) => {
event.preventDefault();
onSubmit();
}}
className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-4 py-4"
>
<div className="space-y-4">
<label
className="block space-y-1.5 text-xs font-medium"
htmlFor="repository-set-editor-name"
>
<span>{t("workspaces:repositorySetsNameLabel")}</span>
<Input
id="repository-set-editor-name"
value={draft.name}
maxLength={100}
autoFocus
onChange={(event) => onChange({ name: event.target.value })}
data-testid="repository-set-editor-name"
/>
</label>
<label
className="block space-y-1.5 text-xs font-medium"
htmlFor="repository-set-editor-description"
>
<span>{t("workspaces:repositorySetsDescriptionLabel")}</span>
<Textarea
id="repository-set-editor-description"
value={draft.description}
rows={2}
onChange={(event) => onChange({ description: event.target.value })}
data-testid="repository-set-editor-description"
/>
</label>
<RepositorySetMembersField
workspaceId={workspaceId}
draft={draft}
repositories={repositories}
onChange={onChange}
/>
<span className="truncate">{label}</span>
</label>
{checked ? (
<div className="flex shrink-0 gap-0.5">
<Button
size="icon"
variant="ghost"
className="cursor-pointer"
disabled={!canMoveUp}
aria-label={t("workspaces:repositorySetsMoveUp")}
onClick={onMoveUp}
data-testid={`repository-set-move-up-${repositoryId}`}
>
<IconArrowUp className="h-4 w-4" />
</Button>
<Button
size="icon"
variant="ghost"
className="cursor-pointer"
disabled={!canMoveDown}
aria-label={t("workspaces:repositorySetsMoveDown")}
onClick={onMoveDown}
data-testid={`repository-set-move-down-${repositoryId}`}
>
<IconArrowDown className="h-4 w-4" />
</Button>
</div>
) : null}
</div>
{error ? (
<p className="text-xs text-destructive" data-testid="repository-set-editor-error">
{error}
</p>
) : null}
</div>
</form>
);
}
@@ -1,5 +1,6 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { TooltipProvider } from "@kandev/ui/tooltip";
import type { Repository, RepositorySet } from "@/lib/types/http";
import { repositoryId, workspaceId } from "@/lib/types/ids";
@@ -9,6 +10,7 @@ const REPO_GATEWAY = "repo-gateway";
const FULL_STACK = "Full-stack";
const BACKEND = "Backend";
const NAME_INPUT = "repository-set-editor-name";
const ADD_REPOSITORY = "repository-set-add-repository";
const SAVE = "repository-set-editor-save";
const SET_ROW = "repository-set-row";
const CREATE = "repository-set-create";
@@ -38,7 +40,19 @@ vi.mock("@/hooks/domains/workspace/use-repository-sets", () => ({
}));
vi.mock("@/hooks/use-responsive-breakpoint", () => ({
useResponsiveBreakpoint: () => ({ isFinePointer: finePointer }),
useResponsiveBreakpoint: () => ({ isFinePointer: finePointer, isMobile: !finePointer }),
}));
vi.mock("@/hooks/domains/workspace/use-repository-branches", () => ({
useBranches: () => ({
branches: [
{ name: "main", type: "local", remote: "" },
{ name: "develop", type: "local", remote: "" },
],
isLoaded: true,
isLoading: false,
refresh: vi.fn(),
}),
}));
vi.mock("@/components/state-provider", () => ({
@@ -55,7 +69,12 @@ function repository(id: string): Repository {
const AVAILABLE = [repository(REPO_WEB), repository(REPO_GATEWAY)];
function repositorySet(id: string, name: string, ids: string[]): RepositorySet {
function repositorySet(
id: string,
name: string,
ids: string[],
bases: string[] = [],
): RepositorySet {
return {
id,
workspace_id: workspaceId("ws-1"),
@@ -64,6 +83,7 @@ function repositorySet(id: string, name: string, ids: string[]): RepositorySet {
repositories: ids.map((member, position) => ({
repository_id: repositoryId(member),
position,
...(bases[position] ? { base_branch: bases[position] } : {}),
})),
created_at: "2026-08-17T09:00:00Z",
updated_at: "2026-08-17T09:00:00Z",
@@ -72,11 +92,13 @@ function repositorySet(id: string, name: string, ids: string[]): RepositorySet {
function renderSection(options: { readOnly?: boolean } = {}) {
render(
<WorkspaceRepositorySetsSection
workspaceId="ws-1"
repositories={AVAILABLE}
readOnly={options.readOnly ?? false}
/>,
<TooltipProvider>
<WorkspaceRepositorySetsSection
workspaceId="ws-1"
repositories={AVAILABLE}
readOnly={options.readOnly ?? false}
/>
</TooltipProvider>,
);
}
@@ -102,6 +124,15 @@ describe("WorkspaceRepositorySetsSection", () => {
expect(rows[0].textContent).toContain(REPO_GATEWAY);
});
it("shows a saved base branch in the set summary", () => {
mockSets = [repositorySet("set-1", FULL_STACK, [REPO_WEB], ["develop"])];
renderSection();
expect(screen.getByTestId(`repository-set-member-base-${REPO_WEB}`).textContent).toContain(
"develop",
);
});
it("says so when the workspace has no sets yet", () => {
mockSets = [];
renderSection();
@@ -115,14 +146,15 @@ describe("WorkspaceRepositorySetsSection", () => {
fireEvent.click(screen.getByTestId(CREATE));
fireEvent.change(screen.getByTestId(NAME_INPUT), { target: { value: "Backend" } });
fireEvent.click(screen.getByTestId(`repository-set-member-${REPO_GATEWAY}`));
fireEvent.click(screen.getByTestId(ADD_REPOSITORY));
fireEvent.click(screen.getByRole("option", { name: REPO_GATEWAY }));
fireEvent.click(screen.getByTestId(SAVE));
await waitFor(() => expect(mockCreate).toHaveBeenCalledTimes(1));
expect(mockCreate).toHaveBeenCalledWith("ws-1", {
name: "Backend",
description: "",
repositoryIds: [REPO_GATEWAY],
repositories: [{ repositoryId: REPO_GATEWAY, baseBranch: "" }],
});
expect(mockUpsert).toHaveBeenCalledWith(
"ws-1",
@@ -140,23 +172,64 @@ describe("WorkspaceRepositorySetsSection", () => {
// A named set with no member is still not saveable: the API requires one.
expect((screen.getByTestId(SAVE) as HTMLButtonElement).disabled).toBe(true);
fireEvent.click(screen.getByTestId(`repository-set-member-${REPO_GATEWAY}`));
fireEvent.click(screen.getByTestId(ADD_REPOSITORY));
fireEvent.click(screen.getByRole("option", { name: REPO_GATEWAY }));
expect((screen.getByTestId(SAVE) as HTMLButtonElement).disabled).toBe(false);
});
});
describe("WorkspaceRepositorySetsSection member editing", () => {
it("edits an existing set, replacing its whole membership", async () => {
renderSection();
fireEvent.click(screen.getByTestId("repository-set-edit-set-1"));
// Deselect one member, leaving repo-web.
fireEvent.click(screen.getByTestId(`repository-set-member-${REPO_GATEWAY}`));
fireEvent.click(screen.getByTestId(`repository-set-remove-${REPO_GATEWAY}`));
fireEvent.click(screen.getByTestId(SAVE));
await waitFor(() => expect(mockUpdate).toHaveBeenCalledTimes(1));
expect(mockUpdate).toHaveBeenCalledWith("set-1", {
name: FULL_STACK,
description: "",
repositoryIds: [REPO_WEB],
repositories: [{ repositoryId: REPO_WEB, baseBranch: "" }],
});
});
it("saves a selected base branch and can reset it to the task default", async () => {
renderSection();
fireEvent.click(screen.getByTestId(CREATE));
fireEvent.change(screen.getByTestId(NAME_INPUT), { target: { value: BACKEND } });
fireEvent.click(screen.getByTestId(ADD_REPOSITORY));
fireEvent.click(screen.getByRole("option", { name: REPO_GATEWAY }));
fireEvent.click(screen.getByTestId(`repository-set-base-${REPO_GATEWAY}`));
const develop = screen
.getAllByRole("option")
.find((option) => option.textContent?.includes("develop"));
expect(develop).toBeDefined();
fireEvent.click(develop!);
expect((screen.getByTestId("repository-set-reset-bases") as HTMLButtonElement).disabled).toBe(
false,
);
fireEvent.click(screen.getByTestId("repository-set-reset-bases"));
expect((screen.getByTestId("repository-set-reset-bases") as HTMLButtonElement).disabled).toBe(
true,
);
fireEvent.click(screen.getByTestId(`repository-set-base-${REPO_GATEWAY}`));
const developAgain = screen
.getAllByRole("option")
.find((option) => option.textContent?.includes("develop"));
expect(developAgain).toBeDefined();
fireEvent.click(developAgain!);
fireEvent.click(screen.getByTestId(SAVE));
await waitFor(() => expect(mockCreate).toHaveBeenCalledTimes(1));
expect(mockCreate).toHaveBeenCalledWith("ws-1", {
name: BACKEND,
description: "",
repositories: [{ repositoryId: REPO_GATEWAY, baseBranch: "develop" }],
});
});
@@ -169,7 +242,8 @@ describe("WorkspaceRepositorySetsSection", () => {
fireEvent.click(screen.getByTestId(CREATE));
fireEvent.change(screen.getByTestId(NAME_INPUT), { target: { value: FULL_STACK } });
fireEvent.click(screen.getByTestId(`repository-set-member-${REPO_WEB}`));
fireEvent.click(screen.getByTestId(ADD_REPOSITORY));
fireEvent.click(screen.getByRole("option", { name: REPO_WEB }));
fireEvent.click(screen.getByTestId(SAVE));
await waitFor(() => expect(screen.queryByTestId("repository-set-editor-error")).not.toBeNull());
@@ -108,6 +108,7 @@ export function WorkspaceRepositorySetsSection({
</div>
)}
<RepositorySetEditorDialog
workspaceId={workspaceId}
draft={manager.draft}
repositories={repositories}
error={manager.error}
@@ -209,7 +210,17 @@ function RepositorySetRow({
) : (
set.repositories.map((member) => (
<Badge key={member.repository_id} variant="secondary">
{namesById.get(member.repository_id as string)?.name ?? member.repository_id}
<span>
{namesById.get(member.repository_id as string)?.name ?? member.repository_id}
</span>
{member.base_branch ? (
<span
className="ml-1 text-muted-foreground"
data-testid={`repository-set-member-base-${member.repository_id}`}
>
{t("workspaces:repositorySetsMemberBase", { base: member.base_branch })}
</span>
) : null}
</Badge>
))
)}
@@ -41,11 +41,18 @@ export function buildBranchKeywords(name: string, remote?: string): string[] {
return Array.from(out);
}
/** Returns the exact value used by branch picker options and form state. */
export function branchOptionValue(branch: Pick<Branch, "name" | "type" | "remote">): string {
return branch.type === "remote" && branch.remote
? `${branch.remote}/${branch.name}`
: branch.name;
}
export function branchToOption(b: Branch): BranchOption {
// Remote branches keep their "origin/" prefix so they're distinguishable
// from local branches with the same short name (e.g. "main" vs "origin/main").
// Without the prefix, the dropdown shows two indistinguishable rows.
const display = b.type === "remote" && b.remote ? `${b.remote}/${b.name}` : b.name;
const display = branchOptionValue(b);
// `||` (not `??`) so an empty-string `remote` falls back too. Provider-backed
// workspace repos (URL-added) list branches without a tracking remote, so
// the backend sends `remote: ""`; `??` would render an invisible empty badge.
+21
View File
@@ -78,4 +78,25 @@ describe("Combobox", () => {
expect(renderedOptions[0].getAttribute("aria-disabled")).toBe("true");
expect(renderedOptions[0].className).toContain("bg-card");
});
it("notifies open-state listeners when an option selection closes the menu", () => {
const onOpenChange = vi.fn();
const onValueChange = vi.fn();
render(
<Combobox
options={options}
value=""
onValueChange={onValueChange}
onOpenChange={onOpenChange}
ariaLabel="Choose an agent"
/>,
);
fireEvent.click(screen.getByRole("combobox", { name: "Choose an agent" }));
expect(onOpenChange).toHaveBeenLastCalledWith(true);
fireEvent.click(screen.getByRole("option", { name: "First" }));
expect(onValueChange).toHaveBeenCalledWith("first");
expect(onOpenChange).toHaveBeenLastCalledWith(false);
});
});
+59 -14
View File
@@ -71,6 +71,8 @@ interface ComboboxProps {
triggerId?: string;
/** Ref for consumers that anchor a local confirmation to this trigger. */
triggerRef?: Ref<HTMLButtonElement>;
/** Notifies consumers when the popover opens or closes. */
onOpenChange?: (open: boolean) => void;
}
function TriggerLabel({
@@ -224,6 +226,49 @@ function ComboboxTrigger({
</PopoverTrigger>
);
}
function handleComboboxOpenChange(
next: boolean,
value: string,
setOpen: (open: boolean) => void,
setHighlighted: (value: string) => void,
onOpenChange?: (open: boolean) => void,
) {
setOpen(next);
if (next) setHighlighted(value);
onOpenChange?.(next);
}
function selectComboboxOption({
selectedValue,
currentValue,
onValueChange,
setOpen,
onOpenChange,
}: {
selectedValue: string;
currentValue: string;
onValueChange: (value: string) => void;
setOpen: (open: boolean) => void;
onOpenChange?: (open: boolean) => void;
}) {
const nextValue = selectedValue === currentValue ? "" : selectedValue;
onValueChange(nextValue);
setOpen(false);
onOpenChange?.(false);
}
function ComboboxHeader({
dropdownLabel,
headerAction,
}: Pick<ComboboxProps, "dropdownLabel" | "headerAction">) {
if (!dropdownLabel && !headerAction) return null;
return (
<div className="text-muted-foreground flex items-center justify-between gap-2 border-b px-2 py-1 text-xs">
<span>{dropdownLabel}</span>
{headerAction}
</div>
);
}
export const Combobox = memo(function Combobox({
options,
@@ -250,6 +295,7 @@ export const Combobox = memo(function Combobox({
touchTarget = false,
triggerId,
triggerRef,
onOpenChange,
}: ComboboxProps) {
const [open, setOpen] = useState(false);
const portalContainer = useTaskCreateDialogPopoverContainer();
@@ -259,10 +305,9 @@ export const Combobox = memo(function Combobox({
return (
<Popover
open={open}
onOpenChange={(next) => {
setOpen(next);
if (next) setHighlighted(value);
}}
onOpenChange={(next) =>
handleComboboxOpenChange(next, value, setOpen, setHighlighted, onOpenChange)
}
>
<ComboboxTrigger
options={options}
@@ -296,12 +341,7 @@ export const Combobox = memo(function Combobox({
filter={filter}
data-testid={dropdownTestId}
>
{dropdownLabel || headerAction ? (
<div className="text-muted-foreground flex items-center justify-between gap-2 px-2 py-1 text-xs border-b">
<span>{dropdownLabel}</span>
{headerAction}
</div>
) : null}
<ComboboxHeader dropdownLabel={dropdownLabel} headerAction={headerAction} />
{showSearch && (
<CommandInput
placeholder={searchPlaceholder}
@@ -314,10 +354,15 @@ export const Combobox = memo(function Combobox({
options={options}
value={value}
touchTarget={touchTarget}
onSelect={(v) => {
onValueChange(v === value ? "" : v);
setOpen(false);
}}
onSelect={(v) =>
selectComboboxOption({
selectedValue: v,
currentValue: value,
onValueChange,
setOpen,
onOpenChange,
})
}
/>
</CommandList>
</Command>
@@ -0,0 +1,47 @@
"use client";
import { IconPlus } from "@tabler/icons-react";
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
import { useTranslation } from "react-i18next";
import { cn } from "@/lib/utils";
export function AddRepositoryButton({
canAddMore,
addHint,
addLabel,
onAdd,
}: {
canAddMore: boolean;
addHint?: string;
addLabel?: string;
onAdd: () => void;
}) {
const { t } = useTranslation();
return (
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex" tabIndex={canAddMore ? undefined : 0}>
<button
type="button"
onClick={onAdd}
disabled={!canAddMore}
aria-label={t("task:addRepository")}
data-testid="add-repository"
className={cn(
"inline-flex items-center justify-center gap-1.5 rounded-md text-muted-foreground",
addLabel ? "h-11 px-2 text-xs md:h-9" : "h-11 w-11 md:h-7 md:w-7",
canAddMore
? "hover:bg-muted hover:text-foreground cursor-pointer"
: "opacity-40 cursor-not-allowed",
)}
>
<IconPlus className="h-3.5 w-3.5" />
{addLabel ? <span>{addLabel}</span> : null}
</button>
</span>
</TooltipTrigger>
<TooltipContent>{addHint ?? t("task:addAnotherRepository")}</TooltipContent>
</Tooltip>
);
}
@@ -1,4 +1,5 @@
export {
branchOptionValue,
branchToOption,
buildBranchKeywords,
computeBranchPlaceholder,
@@ -262,8 +262,20 @@ function useRepositoryHandlers(fs: DialogFormState, repositories: Repository[])
const wasLocalPath = Boolean(fs.repositories.find((row) => row.key === key)?.localPath);
const isLocalPath = !isWorkspaceRepo && Boolean(value);
const patch: Partial<TaskRepoRow> = isWorkspaceRepo
? { repositoryId: value, localPath: undefined, branch: "", branchPolicyId: undefined }
: { repositoryId: undefined, localPath: value, branch: "", branchPolicyId: undefined };
? {
repositoryId: value,
localPath: undefined,
branch: "",
baseBranch: undefined,
branchPolicyId: undefined,
}
: {
repositoryId: undefined,
localPath: value,
branch: "",
baseBranch: undefined,
branchPolicyId: undefined,
};
fs.updateRepository(key, patch);
if (wasLocalPath !== isLocalPath) {
fs.setExecutorId("");
@@ -291,7 +303,11 @@ function useRepositoryHandlers(fs: DialogFormState, repositories: Repository[])
const handleRowPolicyChange = useCallback(
(key: string, policyId: string, baseBranch: string) => {
fs.updateRepository(key, { branch: baseBranch, branchPolicyId: policyId });
fs.updateRepository(key, {
branch: baseBranch,
baseBranch,
branchPolicyId: policyId,
});
syncTaskCreateLastUsed({ branch: baseBranch });
},
[fs],
@@ -0,0 +1,23 @@
import { describe, expect, it } from "vitest";
import { buildRepositoriesPayload } from "./task-create-dialog-helpers";
describe("buildRepositoriesPayload — saved local base branches", () => {
it("omits checkout when the local checkout already matches a saved base", () => {
const payload = buildRepositoriesPayload({
useRemote: false,
remoteRepos: [],
repositories: [
{ key: "r0", repositoryId: "repo-1", branch: "develop", baseBranch: "develop" },
],
discoveredRepositories: [],
// eslint-disable-next-line @typescript-eslint/no-explicit-any
workspaceRepositories: [{ id: "repo-1", default_branch: "main" }] as any,
isLocalExecutor: true,
});
expect(payload).toEqual([
{ repository_id: "repo-1", base_branch: "develop", checkout_branch: undefined },
]);
});
});
@@ -156,16 +156,10 @@ describe("buildRepositoriesPayload — local executor branch split (core)", () =
describe("buildRepositoriesPayload — local executor branch split (edge cases)", () => {
it("fresh-branch flow: skips the split so the picked base is preserved as base_branch", () => {
// When the user enables "Fork a new branch", the chip's branch is the
// BASE TO FORK FROM (e.g. "develop"), not a working branch. The backend
// creates a new branch from that base and rewrites base_branch to the
// new branch name. If we split here, base_branch would land on the
// repo's default ("main") and the fork would happen from main instead
// of develop — silently wrong.
const payload = buildRepositoriesPayload({
useRemote: false,
remoteRepos: [],
repositories: [{ key: "r0", repositoryId: "repo-1", branch: "develop" }],
repositories: [{ key: "r0", repositoryId: "repo-1", branch: "develop", baseBranch: "main" }],
discoveredRepositories: [],
// eslint-disable-next-line @typescript-eslint/no-explicit-any
workspaceRepositories: [{ id: "repo-1", default_branch: "main" }] as any,
@@ -185,13 +179,6 @@ describe("buildRepositoriesPayload — local executor branch split (edge cases)"
});
it("falls through when default_branch is unknown (legacy repos)", () => {
// Repos created before the backend probe fix may have an unset
// default_branch in the workspace store. If we synthesize base_branch=
// rowBranch here (as the original draft did), we reproduce the very bug
// this PR fixes: agentctl recomputes merge-base(HEAD, origin/<rowBranch>)
// → collapses to HEAD → empty changes panel. Better to leave the legacy
// shape alone — the next backend createRepository call will populate
// default_branch via the gitref probe.
const payload = buildRepositoriesPayload({
useRemote: false,
remoteRepos: [],
@@ -220,6 +207,40 @@ describe("buildRepositoriesPayload — local executor branch split (edge cases)"
{ repository_id: "repo-1", base_branch: "main", checkout_branch: undefined },
]);
});
it("uses a copied saved base for worktree execution", () => {
const payload = buildRepositoriesPayload({
useRemote: false,
remoteRepos: [],
repositories: [{ key: "r0", repositoryId: "repo-1", branch: "", baseBranch: "develop" }],
discoveredRepositories: [],
// eslint-disable-next-line @typescript-eslint/no-explicit-any
workspaceRepositories: [{ id: "repo-1", default_branch: "main" }] as any,
isLocalExecutor: false,
});
expect(payload).toEqual([
{ repository_id: "repo-1", base_branch: "develop", checkout_branch: undefined },
]);
});
it("keeps a copied base separate from local checkout state", () => {
const payload = buildRepositoriesPayload({
useRemote: false,
remoteRepos: [],
repositories: [
{ key: "r0", repositoryId: "repo-1", branch: "feature/x", baseBranch: "develop" },
],
discoveredRepositories: [],
// eslint-disable-next-line @typescript-eslint/no-explicit-any
workspaceRepositories: [{ id: "repo-1", default_branch: "main" }] as any,
isLocalExecutor: true,
});
expect(payload).toEqual([
{ repository_id: "repo-1", base_branch: "develop", checkout_branch: "feature/x" },
]);
});
});
describe("buildRepositoriesPayload — single-row and URL mode (core)", () => {
@@ -10,6 +10,7 @@ import type {
import type { UsePRInfoByURLResult } from "@/hooks/domains/github/use-pr-info-by-url";
import { parseGitHubAnyUrl } from "@/hooks/domains/github/use-pr-info-by-url";
import { selectPreferredBranch } from "@/lib/utils";
import { branchOptionValue } from "@/components/branch-picker-options";
import { createDebugLogger } from "@/lib/debug/log";
import { useContextFilesStore } from "@/lib/state/context-files-store";
import { linkToTask } from "@/lib/links";
@@ -84,13 +85,7 @@ export function autoSelectBranch(
}
function isBranchSelectable(branchList: Branch[], value: string | null | undefined) {
return Boolean(value && branchList.some((branch) => branchDisplayName(branch) === value));
}
function branchDisplayName(branch: Branch) {
return branch.type === "remote" && branch.remote
? `${branch.remote}/${branch.name}`
: branch.name;
return Boolean(value && branchList.some((branch) => branchOptionValue(branch) === value));
}
export function computePassthroughProfile(
@@ -336,6 +331,10 @@ export function buildRepositoriesPayload(opts: {
const branches = splitLocalExecutorBranches({
rowBranch: row.branch,
defaultBranch,
// Fresh-branch mode uses row.branch as the fork base. A saved set base
// is checkout metadata for the ordinary local-executor flow and must
// not override the branch the user picked to fork from.
baseBranch: opts.freshBranch ? undefined : row.baseBranch,
isLocalExecutor,
});
if (row.repositoryId) {
@@ -492,6 +491,7 @@ function resolveRowDefaultBranch(
function splitLocalExecutorBranches(args: {
rowBranch?: string;
defaultBranch?: string;
baseBranch?: string;
isLocalExecutor: boolean;
}): { base_branch: string | undefined; checkout_branch: string | undefined } {
// Without a known default_branch we can't anchor base_branch to the
@@ -501,11 +501,19 @@ function splitLocalExecutorBranches(args: {
// unset default_branch is no worse off than before, and the backend's
// resolveRepoInput probe will populate it on the next CreateRepository
// call. Wait for that probe rather than synthesizing a guess here.
if (!args.isLocalExecutor || !args.defaultBranch) {
return { base_branch: args.rowBranch || undefined, checkout_branch: undefined };
if (!args.isLocalExecutor) {
return {
base_branch: args.baseBranch || args.rowBranch || undefined,
checkout_branch: undefined,
};
}
const base = args.defaultBranch;
const checkout =
args.rowBranch && args.rowBranch !== args.defaultBranch ? args.rowBranch : undefined;
return { base_branch: base, checkout_branch: checkout };
if (!args.defaultBranch) {
return {
base_branch: args.baseBranch || args.rowBranch || undefined,
checkout_branch: args.baseBranch ? args.rowBranch || undefined : undefined,
};
}
const base = args.baseBranch || args.defaultBranch;
const checkout = args.rowBranch && args.rowBranch !== base ? args.rowBranch : undefined;
return { base_branch: args.baseBranch || base, checkout_branch: checkout };
}
@@ -25,7 +25,7 @@ import { formatUserHomePath, truncateRepoPath } from "@/lib/utils";
import { getExecutorIcon } from "@/lib/executor-icons";
import { AgentLogo } from "@/components/agent-logo";
import { getCapabilityWarning } from "@/lib/capability-warning";
import { buildBranchKeywords } from "./branch-picker-options";
import { branchOptionValue, buildBranchKeywords } from "./branch-picker-options";
import {
ensureAgentProfileRecentUseLoaded,
orderAgentProfilesByRecentUse,
@@ -103,10 +103,7 @@ export function useRepositoryOptions(
export function useBranchOptions(branchOptionsRaw: Branch[]) {
return useMemo(() => {
return branchOptionsRaw.map((branchObj: Branch) => {
const displayName =
branchObj.type === "remote" && branchObj.remote
? `${branchObj.remote}/${branchObj.name}`
: branchObj.name;
const displayName = branchOptionValue(branchObj);
// Keywords give the scorer extra surfaces to match against: the leaf
// branch name, every path segment, and (for remotes) the remote name.
const keywords = buildBranchKeywords(branchObj.name, branchObj.remote);
@@ -81,7 +81,12 @@ vi.mock("@kandev/ui/tooltip", async () => {
});
import { Pill, type PillOption } from "./task-create-dialog-pill";
import { branchToOption, computeBranchPlaceholder, sortBranches } from "./branch-picker-options";
import {
branchOptionValue,
branchToOption,
computeBranchPlaceholder,
sortBranches,
} from "./branch-picker-options";
const CREATE_REPOSITORY = "Create new repository";
@@ -190,6 +195,12 @@ describe("grouped pill options", () => {
});
describe("branchToOption keywords", () => {
it("uses the qualified value for remote branches", () => {
expect(branchOptionValue(remoteBranch("main"))).toBe("origin/main");
expect(branchToOption(remoteBranch("main")).value).toBe("origin/main");
expect(branchOptionValue(localBranch("main"))).toBe("main");
});
it("tags branch options for the grouped selector", () => {
expect(branchToOption(localBranch("main"))).toMatchObject({
group: "branches",
+64 -41
View File
@@ -43,7 +43,6 @@ export type PillAction = {
* correct. Contextual controls can use `popoverHeader`, which renders outside
* `Command`; mixed searchable content still needs a custom `Popover`.
*/
type PillProps = {
icon: React.ReactNode;
value: string;
@@ -58,17 +57,17 @@ type PillProps = {
searchPlaceholder: string;
emptyMessage: string;
testId?: string;
triggerClassName?: string;
ariaLabel?: string;
dropdownTestId?: string;
onOpenChange?: (open: boolean) => void;
/** Optional refresh action rendered next to the search input. */
onRefresh?: () => void;
/** Show the refresh icon as spinning + disabled while a refresh is in flight. */
refreshing?: boolean;
/** Accessible label used for the optional refresh action. */
refreshLabel?: string;
/**
* Render without its own border/bg so the pill blends into a wrapping
* grouped container (used by RepoChip to draw one rectangle around
* repo + branch + remove).
*/
/** Render without its own border/bg for a grouped repo chip. */
flat?: boolean;
/** Optional cmdk scorer override. Branch pickers pass `scoreBranch`. */
filter?: (value: string, search: string, keywords?: string[]) => number;
@@ -215,22 +214,6 @@ function DisabledPillTooltip({
);
}
function renderDisabledPillTooltip(
tooltipOpenState: boolean,
onOpenChange: (open: boolean) => void,
triggerButton: React.ReactNode,
disabledReason: string,
): React.ReactElement {
return (
<DisabledPillTooltip
open={tooltipOpenState}
onOpenChange={onOpenChange}
triggerButton={triggerButton}
disabledReason={disabledReason}
/>
);
}
function PillPopoverContent({
filter,
searchPlaceholder,
@@ -246,6 +229,7 @@ function PillPopoverContent({
portalContainer,
action,
popoverHeader,
dropdownTestId,
}: {
filter?: PillProps["filter"];
searchPlaceholder: string;
@@ -261,12 +245,14 @@ function PillPopoverContent({
portalContainer: HTMLElement | null;
action?: PillAction;
popoverHeader?: React.ReactNode;
dropdownTestId?: string;
}) {
return (
<PopoverContent
className="w-[min(480px,calc(100vw-2rem))] p-0"
align="start"
portalContainer={portalContainer}
data-testid={dropdownTestId}
>
{popoverHeader}
<Command filter={filter}>
@@ -333,6 +319,7 @@ function PillPopover({
portalContainer,
action,
popoverHeader,
dropdownTestId,
}: {
open: boolean;
setOpen: (open: boolean) => void;
@@ -350,6 +337,7 @@ function PillPopover({
portalContainer: HTMLElement | null;
action?: PillAction;
popoverHeader?: React.ReactNode;
dropdownTestId?: string;
}) {
return (
<Popover open={open} onOpenChange={setOpen}>
@@ -369,6 +357,7 @@ function PillPopover({
portalContainer={portalContainer}
action={action}
popoverHeader={popoverHeader}
dropdownTestId={dropdownTestId}
/>
</Popover>
);
@@ -391,6 +380,7 @@ type PillPopoverShellProps = {
portalContainer: HTMLElement | null;
action?: PillAction;
popoverHeader?: React.ReactNode;
dropdownTestId?: string;
tooltip?: string;
tooltipOpenState: boolean;
suppressTooltip: boolean;
@@ -416,6 +406,7 @@ function renderPillPopover({
portalContainer,
action,
popoverHeader,
dropdownTestId,
tooltip,
tooltipOpenState,
suppressTooltip,
@@ -444,6 +435,7 @@ function renderPillPopover({
portalContainer={portalContainer}
action={action}
popoverHeader={popoverHeader}
dropdownTestId={dropdownTestId}
/>
);
@@ -467,11 +459,24 @@ function renderPillTriggerButton({
flat,
hasValue,
testId,
triggerClassName,
ariaLabel,
prefix,
onPointerEnter,
onPointerLeave,
onBlur,
}: Pick<PillProps, "icon" | "value" | "placeholder" | "disabled" | "flat" | "testId" | "prefix"> & {
}: Pick<
PillProps,
| "icon"
| "value"
| "placeholder"
| "disabled"
| "flat"
| "testId"
| "triggerClassName"
| "ariaLabel"
| "prefix"
> & {
hasValue: boolean;
onPointerEnter?: React.PointerEventHandler<HTMLButtonElement>;
onPointerLeave?: React.PointerEventHandler<HTMLButtonElement>;
@@ -482,8 +487,9 @@ function renderPillTriggerButton({
<button
type="button"
disabled={disabled}
aria-label={ariaLabel}
data-testid={testId}
className={pillTriggerClass(Boolean(disabled), Boolean(flat), hasValue)}
className={cn(pillTriggerClass(Boolean(disabled), Boolean(flat), hasValue), triggerClassName)}
onPointerEnter={onPointerEnter}
onPointerLeave={onPointerLeave}
onBlur={onBlur}
@@ -501,6 +507,7 @@ function usePillOpenHandlers(
setOpenState: React.Dispatch<React.SetStateAction<boolean>>,
closeTooltip: () => void,
suppressTooltipUntilLeave: (releaseOnExit?: boolean) => void,
onOpenChange?: (open: boolean) => void,
) {
const selectionPointerTypeRef = useRef("");
const suppressForSelection = useCallback(() => {
@@ -511,12 +518,11 @@ function usePillOpenHandlers(
if (next) {
closeTooltip();
selectionPointerTypeRef.current = "";
} else {
suppressForSelection();
}
} else suppressForSelection();
setOpenState(next);
onOpenChange?.(next);
},
[closeTooltip, setOpenState, suppressForSelection],
[closeTooltip, onOpenChange, setOpenState, suppressForSelection],
);
const recordPointerSelection = useCallback((pointerType: string) => {
selectionPointerTypeRef.current = pointerType;
@@ -524,6 +530,19 @@ function usePillOpenHandlers(
return { setOpen, suppressForSelection, recordPointerSelection };
}
function useTooltipOpenChange(
suppressTooltipRef: { current: boolean },
handleTooltipOpenChange: (open: boolean) => void,
) {
return useCallback(
(next: boolean) => {
if (next && suppressTooltipRef.current) return;
handleTooltipOpenChange(next);
},
[handleTooltipOpenChange],
);
}
/**
* Compact pill trigger that opens a popover with a search list. Auto-widths
* to its content (no `w-full`, no chevron) so multiple pills can sit on one
@@ -545,6 +564,10 @@ export function Pill({
refreshing,
refreshLabel,
flat = false,
triggerClassName,
ariaLabel,
dropdownTestId,
onOpenChange,
filter,
tooltip,
prefix,
@@ -566,14 +589,9 @@ export function Pill({
setOpenState,
closeTooltip,
suppressTooltipUntilLeave,
onOpenChange,
);
const handlePillTooltipOpenChange = useCallback(
(next: boolean) => {
if (next && suppressTooltipRef.current) return;
handleTooltipOpenChange(next);
},
[handleTooltipOpenChange],
);
const handleTooltipChange = useTooltipOpenChange(suppressTooltipRef, handleTooltipOpenChange);
const triggerButton = renderPillTriggerButton({
icon,
value,
@@ -582,6 +600,8 @@ export function Pill({
flat,
hasValue: Boolean(value),
testId,
triggerClassName,
ariaLabel,
prefix,
onPointerEnter: tooltip ? handlePointerEnter : undefined,
onPointerLeave: tooltip ? handlePointerLeave : undefined,
@@ -589,11 +609,13 @@ export function Pill({
});
// Disabled buttons swallow events, so the wrapper owns tooltip focus.
if (disabled && disabledReason && !open) {
return renderDisabledPillTooltip(
tooltipOpenState,
handleTooltipOpenChange,
triggerButton,
disabledReason,
return (
<DisabledPillTooltip
open={tooltipOpenState}
onOpenChange={handleTooltipOpenChange}
triggerButton={triggerButton}
disabledReason={disabledReason}
/>
);
}
@@ -618,11 +640,12 @@ export function Pill({
portalContainer,
action,
popoverHeader,
dropdownTestId,
tooltip,
tooltipOpenState,
suppressTooltip,
suppressTooltipRef,
handlePillTooltipOpenChange,
handlePillTooltipOpenChange: handleTooltipChange,
suppressForSelection,
});
}
@@ -22,7 +22,9 @@ export function computeHasAllBranches(fs: DialogFormState): boolean {
const rows = fs.remoteRepos.filter((r) => r.url.trim() !== "");
return rows.length > 0 && rows.every((r) => !!r.branch);
}
return fs.repositories.length > 0 && fs.repositories.every((r) => !!r.branch);
return (
fs.repositories.length > 0 && fs.repositories.every((r) => Boolean(r.baseBranch || r.branch))
);
}
export function localRepositoryCreationEnabled(isCreateMode: boolean, repoLocked: boolean) {
@@ -162,7 +164,10 @@ export function buildDialogFooterProps(
onUpdateWithoutAgent: submitHandlers.handleUpdateWithoutAgent,
onCreateWithoutAgent: submitHandlers.handleCreateWithoutAgent,
onCreateWithPlanMode: submitHandlers.handleCreateWithPlanMode,
submitBlockedReason: props.submitBlockedReason ?? pendingAttachmentUploadReason,
submitBlockedReason:
props.submitBlockedReason ??
pendingAttachmentUploadReason ??
setup.savedBaseSubmitBlockedReason,
editDependenciesReady: setup.isEditMode ? setup.editDependencies.ready : undefined,
};
}
@@ -0,0 +1,114 @@
import { Badge } from "@kandev/ui/badge";
import type { Branch, LocalRepository, Repository } from "@/lib/types/http";
import type { TaskRepoRow } from "@/components/task-create-dialog-types";
import { branchToOption, sortBranches } from "@/components/branch-picker-options";
import { type PillOption } from "@/components/task-create-dialog-pill";
import { t } from "@/lib/i18n";
export function buildRepoBaseBranchData({
branches,
branchesLoaded,
savedBaseBranch,
row,
repositories,
discoveredRepositories,
}: {
branches: Branch[];
branchesLoaded: boolean;
savedBaseBranch?: string;
row: TaskRepoRow;
repositories: Repository[];
discoveredRepositories: LocalRepository[];
}): { baseBranchOptions: PillOption[]; defaultBranch: string } {
const workspaceRepo = repositories.find((repository) => repository.id === row.repositoryId);
const discoveredRepo = discoveredRepositories.find(
(repository) => repository.path === row.localPath,
);
const defaultBranch = workspaceRepo?.default_branch ?? discoveredRepo?.default_branch ?? "";
return {
defaultBranch,
baseBranchOptions: buildBaseBranchOptions({
branches,
branchesLoaded,
savedBaseBranch,
defaultBranch,
}),
};
}
export function buildBaseBranchOptions({
branches,
branchesLoaded,
savedBaseBranch,
defaultBranch,
}: {
branches: Branch[];
branchesLoaded: boolean;
savedBaseBranch?: string;
defaultBranch: string;
}): PillOption[] {
const available = sortBranches(branches).map(branchToOption);
const savedBaseOption = savedBaseBranch
? savedBaseBranchOption(savedBaseBranch, branchesLoaded, available)
: null;
return [
...(savedBaseOption ? [savedBaseOption] : []),
taskDefaultBranchOption(defaultBranch),
...available,
];
}
function savedBaseBranchOption(
branch: string,
branchesLoaded: boolean,
available: PillOption[],
): PillOption | null {
if (available.some((option) => option.value === branch)) return null;
return branchesLoaded ? unavailableBranchToOption(branch) : savedBranchToOption(branch);
}
export function unavailableBranchToOption(branch: string): PillOption {
return {
value: branch,
label: branch,
keywords: [branch],
group: "branches",
groupLabel: t("task:branchesGroup"),
disabled: true,
disabledReason: t("task:branchUnavailable"),
renderLabel: () => (
<span className="flex min-w-0 flex-1 items-center justify-between gap-2">
<span className="truncate" title={branch}>
{branch}
</span>
<Badge variant="outline" className="shrink-0 text-xs">
{t("task:branchUnavailableShort")}
</Badge>
</span>
),
};
}
function savedBranchToOption(branch: string): PillOption {
return {
value: branch,
label: branch,
keywords: [branch],
group: "branches",
groupLabel: t("task:branchesGroup"),
};
}
function taskDefaultBranchOption(defaultBranch: string): PillOption {
const label = defaultBranch
? t("workspaces:repositorySetsTaskDefault", { branch: defaultBranch })
: t("workspaces:repositorySetsTaskDefaultNoBranch");
return {
value: "",
label,
keywords: [label, defaultBranch].filter(Boolean),
group: "branches",
groupLabel: t("task:branchesGroup"),
};
}
@@ -15,7 +15,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
import { Pill, type PillAction, type PillOption } from "@/components/task-create-dialog-pill";
import type { Branch, RepositoryBranchPolicy } from "@/lib/types/http";
import type { TaskRepoRow } from "@/components/task-create-dialog-types";
import { computeBranchPlaceholder } from "@/components/branch-picker-options";
import { branchOptionValue, computeBranchPlaceholder } from "@/components/branch-picker-options";
import {
computeBranchDisabledReason,
computeBranchPrefix,
@@ -81,10 +81,9 @@ function branchPolicyToOption(
branches: Branch[],
policyDisabledReason?: string,
): PillOption {
const baseBranchAvailable = branches.some((branch) => {
if (branch.name === policy.base_branch) return true;
return branch.type === "remote" && `${branch.remote}/${branch.name}` === policy.base_branch;
});
const baseBranchAvailable = branches.some(
(branch) => branchOptionValue(branch) === policy.base_branch,
);
const unavailableReason =
policyDisabledReason ?? (baseBranchAvailable ? undefined : t("task:branchPolicyUnavailable"));
const summary = t("workspaces:branchPolicySummary", {
@@ -128,6 +127,7 @@ function branchPolicyToOption(
export function useRepoChipBranchPicker({
row,
branchValue = row.branch,
branchPolicies,
branches,
branchOptions,
@@ -139,6 +139,7 @@ export function useRepoChipBranchPicker({
onPolicySelected,
}: {
row: TaskRepoRow;
branchValue?: string;
branchPolicies: RepositoryBranchPolicy[];
branches: Branch[];
branchOptions: PillOption[];
@@ -151,7 +152,7 @@ export function useRepoChipBranchPicker({
}) {
const { t } = useTranslation();
const hasRepo = !!(row.repositoryId || row.localPath);
const branchValue = preferredDefaultBranchLoading ? "" : row.branch;
const visibleBranchValue = preferredDefaultBranchLoading ? "" : branchValue;
const selectedPolicy = branchPolicies.find((policy) => policy.id === row.branchPolicyId);
const policyOptions = useMemo(
() =>
@@ -162,13 +163,13 @@ export function useRepoChipBranchPicker({
() => [...policyOptions, ...branchOptions],
[branchOptions, policyOptions],
);
const selectedBranchValue = selectedPolicy ? `policy:${selectedPolicy.id}` : branchValue;
const selectedBranchValue = selectedPolicy ? "policy:" + selectedPolicy.id : visibleBranchValue;
const selectedBranchLabel = selectedPolicy
? t("task:branchPolicySelected", {
name: selectedPolicy.name,
base: selectedPolicy.base_branch,
})
: branchValue;
: visibleBranchValue;
const handleBranchSelect = (value: string) => {
if (value.startsWith("policy:")) {
const policy = branchPolicies.find((candidate) => `policy:${candidate.id}` === value);
@@ -296,3 +297,56 @@ export function RepoChipBranchPill({
/>
);
}
export function RepoChipBaseBranchPill({
options,
value,
defaultBranch,
hasRepo,
branchesLoading,
onSelect,
refreshBranches,
}: {
options: PillOption[];
value: string;
defaultBranch: string;
hasRepo: boolean;
branchesLoading: boolean;
onSelect: (value: string) => void;
refreshBranches?: () => void;
}) {
const { t } = useTranslation();
const defaultLabel = defaultBranch || t("common:repositoryDefaultBranchOption");
const valueLabel = value || t("workspaces:repositorySetsTaskDefault", { branch: defaultLabel });
const disabledReason = baseBranchDisabledReason(hasRepo, branchesLoading, t);
return (
<Pill
icon={<IconGitBranch className="h-3 w-3 shrink-0 text-muted-foreground" />}
value={valueLabel}
selectedValue={value}
placeholder={valueLabel}
options={options}
onSelect={onSelect}
disabled={!hasRepo || branchesLoading || options.length === 0}
disabledReason={disabledReason}
searchPlaceholder={t("task:searchBranches")}
emptyMessage={t("task:noBranches")}
testId="repo-chip-base-branch"
tooltip={t("workspaces:repositorySetsBaseBranchLabel")}
onRefresh={refreshBranches}
refreshing={branchesLoading}
filter={scoreBranch}
flat
/>
);
}
function baseBranchDisabledReason(
hasRepo: boolean,
branchesLoading: boolean,
translate: (key: string) => string,
): string {
if (!hasRepo) return translate("task:selectRepositoryFirst");
if (branchesLoading) return translate("task:loadingBranches2");
return translate("task:noBranches");
}
@@ -0,0 +1,127 @@
import { IconCheck } from "@tabler/icons-react";
import { Badge } from "@kandev/ui/badge";
import { useTranslation } from "react-i18next";
import type { LocalRepository, Repository } from "@/lib/types/http";
import type { TaskRepoRow } from "@/components/task-create-dialog-types";
import { type PillOption } from "@/components/task-create-dialog-pill";
import { formatUserHomePath } from "@/lib/utils";
import { t } from "@/lib/i18n";
export function normalizeRepoPath(path: string): string {
return path.replace(/\\/g, "/").replace(/\/+$/g, "");
}
export function leafSegment(path: string): string {
const cleaned = normalizeRepoPath(path);
const idx = cleaned.lastIndexOf("/");
return idx >= 0 ? cleaned.slice(idx + 1) : cleaned;
}
export function computeRepoChipDisplay(
row: TaskRepoRow,
repositories: Repository[],
discoveredRepositories: LocalRepository[],
) {
const workspaceRepo = repositories.find((repository) => repository.id === row.repositoryId);
const discoveredRepo = discoveredRepositories.find(
(repository) => repository.path === row.localPath,
);
const repoLabel = workspaceRepo?.name ?? (discoveredRepo ? leafSegment(discoveredRepo.path) : "");
const repoPath = workspaceRepo?.local_path || discoveredRepo?.path || "";
const repoTooltip = repoPath
? t("task:repositoryWithPath", { path: formatUserHomePath(repoPath) })
: t("task:repository2");
return { repoLabel, repoTooltip };
}
export function buildRepoOptions(
filteredRepos: Repository[],
filteredDiscovered: LocalRepository[],
selectedElsewhere: Set<string>,
): PillOption[] {
return [
...filteredRepos.map((repository) => ({
value: repository.id,
label: repository.name,
keywords: [
repository.name,
repository.local_path,
formatUserHomePath(repository.local_path),
].filter((value): value is string => !!value),
renderLabel: () =>
renderWorkspaceRepoOption(
repository,
selectedElsewhere.has(repoIdIdentity(repository.id)) ||
(!!repository.local_path &&
selectedElsewhere.has(repoPathIdentity(repository.local_path))),
),
})),
...filteredDiscovered.map((repository) => ({
value: repository.path,
label: leafSegment(repository.path),
keywords: [repository.path, formatUserHomePath(repository.path)],
renderLabel: () =>
renderDiscoveredRepoOption(
repository.path,
selectedElsewhere.has(repoPathIdentity(repository.path)),
),
})),
];
}
function repoIdIdentity(id: string): string {
return `id:${id}`;
}
function repoPathIdentity(path: string): string {
return `path:${normalizeRepoPath(path)}`;
}
function renderWorkspaceRepoOption(repo: Repository, alreadyAdded: boolean) {
const display = repo.local_path ? formatUserHomePath(repo.local_path) : "";
return (
<span
className="flex min-w-0 flex-1 items-center gap-2 overflow-hidden"
title={display || repo.name}
>
<span className="flex min-w-0 flex-1 flex-col overflow-hidden">
<span className="truncate">{repo.name}</span>
{display ? (
<span className="truncate text-[11px] text-muted-foreground">{display}</span>
) : null}
</span>
{alreadyAdded ? <AlreadyAddedMarker /> : null}
</span>
);
}
function renderDiscoveredRepoOption(path: string, alreadyAdded: boolean) {
const display = formatUserHomePath(path);
return (
<span className="flex min-w-0 flex-1 items-center gap-2 overflow-hidden" title={display}>
<span className="flex min-w-0 flex-1 flex-col overflow-hidden">
<span className="truncate">{leafSegment(path)}</span>
<span className="truncate text-[11px] text-muted-foreground">{display}</span>
</span>
<Badge variant="outline" className="text-[10px] text-muted-foreground shrink-0">
{t("task:onDisk")}
</Badge>
{alreadyAdded ? <AlreadyAddedMarker /> : null}
</span>
);
}
function AlreadyAddedMarker() {
const { t } = useTranslation();
return (
<span
role="img"
aria-label={t("task:alreadyAdded")}
data-testid="already-added-repository-marker"
className="shrink-0 text-primary"
>
<IconCheck aria-hidden="true" className="h-4 w-4" />
</span>
);
}
@@ -7,9 +7,11 @@ import { TooltipProvider } from "@kandev/ui/tooltip";
// The unified branch mock lets tests override results and inspect whether
// chips select an id-based or path-based source.
const lastBranchSource = vi.hoisted((): { value: unknown } => ({ value: null }));
const mockBranches = vi.hoisted((): { value: { branches: Branch[]; isLoading: boolean } } => ({
value: { branches: [], isLoading: false },
}));
const mockBranches = vi.hoisted(
(): { value: { branches: Branch[]; isLoading: boolean; isLoaded?: boolean } } => ({
value: { branches: [], isLoading: false, isLoaded: false },
}),
);
const mockPolicies = vi.hoisted((): { value: RepositoryBranchPolicy[] } => ({ value: [] }));
vi.mock("@/hooks/domains/workspace/use-repository-branches", () => ({
@@ -51,13 +53,14 @@ import { RepoChipsRow } from "./task-create-dialog-repo-chips";
afterEach(() => {
cleanup();
mockBranches.value = { branches: [], isLoading: false };
mockBranches.value = { branches: [], isLoading: false, isLoaded: false };
mockPolicies.value = [];
});
const REPO_FRONT_ID = "repo-front";
const REPO_BACK_ID = "repo-back";
const REPO_CHIP_TRIGGER = "repo-chip-trigger";
const BRANCH_CHIP_TRIGGER = "branch-chip-trigger";
const DISCOVERED_REPO_PATH = "/home/me/projects/local-project";
function makeRepo(id: string, name: string): Repository {
@@ -277,6 +280,116 @@ describe("RepoChipsRow", () => {
expect(onRowBranchChange).toHaveBeenCalledWith("r0", "main");
});
it("keeps a saved worktree base out of the checkout branch state", () => {
mockBranches.value = {
branches: [
{ name: "main", type: "local" } as Branch,
{ name: "develop", type: "local" } as Branch,
],
isLoading: false,
};
const fs = makeFs({
repositories: [
row({
key: "r0",
repositoryId: REPO_FRONT_ID,
branch: "feature/task",
baseBranch: "develop",
}),
],
});
const onRowBranchChange = vi.fn();
renderInProvider(
<RepoChipsRow
fs={fs}
repositories={[makeRepo(REPO_FRONT_ID, "frontend")]}
isTaskStarted={false}
workspaceId="ws-1"
onRowRepositoryChange={NOOP}
onRowBranchChange={onRowBranchChange}
/>,
);
fireEvent.click(screen.getByTestId(BRANCH_CHIP_TRIGGER));
fireEvent.click(screen.getByRole("option", { name: /^main/ }));
expect(fs.updateRepository).toHaveBeenCalledWith("r0", { baseBranch: "main" });
expect(onRowBranchChange).not.toHaveBeenCalled();
});
it("lets an applied saved base reset to the task default", () => {
mockBranches.value = {
branches: [
{ name: "main", type: "local" } as Branch,
{ name: "develop", type: "local" } as Branch,
],
isLoading: false,
isLoaded: true,
};
const fs = makeFs({
repositories: [
row({
key: "r0",
repositoryId: REPO_FRONT_ID,
branch: "feature/task",
baseBranch: "develop",
}),
],
});
const onRowBranchChange = vi.fn();
renderInProvider(
<RepoChipsRow
fs={fs}
repositories={[makeRepo(REPO_FRONT_ID, "frontend")]}
isTaskStarted={false}
workspaceId="ws-1"
onRowRepositoryChange={NOOP}
onRowBranchChange={onRowBranchChange}
/>,
);
fireEvent.click(screen.getByTestId(BRANCH_CHIP_TRIGGER));
fireEvent.click(screen.getByRole("option", { name: /^Task default/ }));
expect(fs.updateRepository).toHaveBeenCalledWith("r0", { baseBranch: undefined });
expect(onRowBranchChange).not.toHaveBeenCalled();
});
it("lets an unavailable applied saved base reset to the task default", () => {
mockBranches.value = {
branches: [{ name: "main", type: "local" } as Branch],
isLoading: false,
isLoaded: true,
};
const fs = makeFs({
repositories: [
row({
key: "r0",
repositoryId: REPO_FRONT_ID,
branch: "feature/task",
baseBranch: "retired",
}),
],
});
renderInProvider(
<RepoChipsRow
fs={fs}
repositories={[makeRepo(REPO_FRONT_ID, "frontend")]}
isTaskStarted={false}
workspaceId="ws-1"
onRowRepositoryChange={NOOP}
onRowBranchChange={NOOP}
/>,
);
fireEvent.click(screen.getByTestId(BRANCH_CHIP_TRIGGER));
const unavailable = screen.getByRole("option", { name: /retired/ });
expect(unavailable.getAttribute("aria-disabled")).toBe("true");
fireEvent.click(screen.getByRole("option", { name: /^Task default/ }));
expect(fs.updateRepository).toHaveBeenCalledWith("r0", { baseBranch: undefined });
});
it("disables branch policies for multi-repo local execution", () => {
mockBranches.value = {
branches: [{ name: "main", type: "local" } as Branch],
@@ -313,7 +426,7 @@ describe("RepoChipsRow", () => {
/>,
);
fireEvent.click(screen.getAllByTestId("branch-chip-trigger")[0]);
fireEvent.click(screen.getAllByTestId(BRANCH_CHIP_TRIGGER)[0]);
const option = screen.getByRole("option", { name: /Feature policy/ });
expect(option.getAttribute("aria-disabled")).toBe("true");
@@ -521,7 +634,7 @@ describe("RepoChipsRow", () => {
onRowBranchChange={NOOP}
/>,
);
fireEvent.click(screen.getByTestId("branch-chip-trigger"));
fireEvent.click(screen.getByTestId(BRANCH_CHIP_TRIGGER));
expect(screen.getByText("main")).toBeTruthy();
expect(screen.getByText("origin/main")).toBeTruthy();
mockBranches.value = { branches: [], isLoading: false };
@@ -78,12 +78,53 @@ type RepoChipsRowProps = {
save?: {
workspaceId: string;
rows: TaskRepoRow[];
repositories: Repository[];
isLocalExecutor: boolean;
freshBranchEnabled: boolean;
open: boolean;
setOpen: (open: boolean) => void;
} | null;
};
};
function useRepositoryCreationOpenChange(
chipRowRef: { current: HTMLDivElement | null },
creatingForRowKey: string | null,
setCreatingForRowKey: (value: string | null) => void,
) {
return (open: boolean) => {
if (open || creatingForRowKey === null) return;
const rowKey = creatingForRowKey;
setCreatingForRowKey(null);
requestAnimationFrame(() => {
const row = Array.from(
chipRowRef.current?.querySelectorAll<HTMLElement>("[data-repo-row-key]") ?? [],
).find((candidate) => candidate.dataset.repoRowKey === rowKey);
row?.querySelector<HTMLElement>("[data-testid='repo-chip-trigger']")?.focus();
});
};
}
function applyRowBranchChange(
fs: DialogFormState,
isLocalExecutor: boolean | undefined,
onRowBranchChange: (key: string, value: string) => void,
key: string,
value: string,
) {
const hasSavedWorktreeBase =
!isLocalExecutor && fs.repositories.some((row) => row.key === key && row.baseBranch);
if (hasSavedWorktreeBase) {
fs.updateRepository(key, { baseBranch: value || undefined });
return;
}
onRowBranchChange(key, value);
}
function updateSavedBaseBranch(fs: DialogFormState, key: string, value: string) {
fs.updateRepository(key, { baseBranch: value || undefined });
}
export function RepoChipsRow({
fs,
repositories,
@@ -109,17 +150,11 @@ export function RepoChipsRow({
}: RepoChipsRowProps) {
const chipRowRef = useRef<HTMLDivElement>(null);
const [creatingForRowKey, setCreatingForRowKey] = useState<string | null>(null);
const handleCreationOpenChange = (open: boolean) => {
if (open || creatingForRowKey === null) return;
const rowKey = creatingForRowKey;
setCreatingForRowKey(null);
requestAnimationFrame(() => {
const row = Array.from(
chipRowRef.current?.querySelectorAll<HTMLElement>("[data-repo-row-key]") ?? [],
).find((candidate) => candidate.dataset.repoRowKey === rowKey);
row?.querySelector<HTMLElement>("[data-testid='repo-chip-trigger']")?.focus();
});
};
const handleCreationOpenChange = useRepositoryCreationOpenChange(
chipRowRef,
creatingForRowKey,
setCreatingForRowKey,
);
// Local executor branch behavior:
// - chip is clickable (user can switch to any existing branch on disk)
// - row.branch seeds from the workspace's current branch (currentLocalBranch)
@@ -131,6 +166,8 @@ export function RepoChipsRow({
// - "Fork a new branch" toggle is a separate flow that creates a NEW branch
// from the selected base
// Other executors: branch is fully editable (no special pre-fill).
const handleRowBranchChange = (key: string, value: string) =>
applyRowBranchChange(fs, isLocalExecutor, onRowBranchChange, key, value);
// No early returns above hooks. URL mode and started-state checks happen below.
if (isTaskStarted) return null;
@@ -156,7 +193,6 @@ export function RepoChipsRow({
fs={fs}
repositories={repositories}
workspaceId={workspaceId}
branchLocked={false}
isLocalExecutor={!!isLocalExecutor}
canAddMore={canAddMore}
addHint={addHint}
@@ -164,7 +200,7 @@ export function RepoChipsRow({
freshBranchEnabled={freshBranchEnabled}
branchPolicyDisabledReason={branchPolicyDisabledReason}
onRowRepositoryChange={onRowRepositoryChange}
onRowBranchChange={onRowBranchChange}
onRowBranchChange={handleRowBranchChange}
onRowPolicyChange={onRowPolicyChange}
onPolicySelected={onPolicySelected}
onToggleFreshBranch={onToggleFreshBranch}
@@ -236,6 +272,9 @@ function RepositorySetsSurface({
onOpenChange={save.setOpen}
workspaceId={save.workspaceId}
rows={save.rows}
repositories={save.repositories}
isLocalExecutor={save.isLocalExecutor}
freshBranchEnabled={save.freshBranchEnabled}
/>
) : null}
</>
@@ -246,7 +285,6 @@ function ModeBody({
fs,
repositories,
workspaceId,
branchLocked,
isLocalExecutor,
canAddMore,
addHint,
@@ -268,7 +306,6 @@ function ModeBody({
fs: DialogFormState;
repositories: Repository[];
workspaceId: string | null;
branchLocked: boolean;
isLocalExecutor: boolean;
canAddMore: boolean;
addHint: string | undefined;
@@ -307,7 +344,7 @@ function ModeBody({
repositories={repositories}
discoveredRepositories={fs.discoveredRepositories}
workspaceId={workspaceId}
branchLocked={branchLocked}
branchLocked={false}
isLocalExecutor={isLocalExecutor}
currentLocalBranch={fs.currentLocalBranch}
currentLocalBranchLoading={fs.currentLocalBranchLoading}
@@ -319,6 +356,7 @@ function ModeBody({
onRemove={fs.removeRepository}
onRowRepositoryChange={onRowRepositoryChange}
onRowBranchChange={onRowBranchChange}
onRowBaseBranchChange={(key, value) => updateSavedBaseBranch(fs, key, value)}
onRowPolicyChange={onRowPolicyChange}
onPolicySelected={onPolicySelected}
showBranchPolicies
@@ -328,12 +366,12 @@ function ModeBody({
onCreateRepository={onCreateRepository}
onRefreshRepositories={onRefreshRepositories}
repositoriesRefreshing={repositoriesRefreshing}
freshBranchToggle={
// Multi-repo worktrees do not need the existing-vs-fork choice.
freshBranchAvailable && onToggleFreshBranch && fs.repositories.length === 1 ? (
<FreshBranchToggle enabled={!!freshBranchEnabled} onToggle={onToggleFreshBranch} />
) : null
}
freshBranchToggle={buildFreshBranchToggle(
fs.repositories.length,
freshBranchAvailable,
freshBranchEnabled,
onToggleFreshBranch,
)}
/>
);
}
@@ -355,6 +393,16 @@ function NoRepositoryMode({
);
}
function buildFreshBranchToggle(
repositoryCount: number,
available: boolean | undefined,
enabled: boolean | undefined,
onToggle?: (enabled: boolean) => void,
) {
if (!available || !onToggle || repositoryCount !== 1) return null;
return <FreshBranchToggle enabled={!!enabled} onToggle={onToggle} />;
}
function FreshBranchToggle({
enabled,
onToggle,
@@ -63,6 +63,52 @@ beforeEach(() => {
afterEach(() => cleanup());
describe("SaveRepositorySetDialog duplicate rows", () => {
it.each([1, 2])("reports %i duplicate rows separately from non-workspace rows", (count) => {
renderDialog({
rows: [
...ROWS,
...Array.from({ length: count }, (_, index) => ({
key: `duplicate-${index}`,
repositoryId: REPO_WEB,
branch: `feature/${index}`,
})),
{ key: "local", localPath: "/src/other", branch: "main" },
],
});
expect(screen.getByTestId("repository-set-save-duplicates").textContent).toBe(
`${count} additional ${count === 1 ? "row is" : "rows are"} not included. Only the first row for each repository is saved, including its base choice.`,
);
expect(screen.getByTestId("repository-set-save-excluded").textContent).toBe(
"1 row is not a saved workspace repository and will not be included",
);
expect(screen.getByText("Saves 2 repositories")).toBeTruthy();
});
it("does not warn about duplicates when all repository rows are unique", () => {
renderDialog();
expect(screen.queryByTestId("repository-set-save-duplicates")).toBeNull();
});
it("saves the first duplicate's base without changing the draft", async () => {
const rows = [...ROWS, { key: "duplicate", repositoryId: REPO_WEB, branch: "feature/x" }];
const originalRows = structuredClone(rows);
renderDialog({ rows });
expect(screen.getByTestId("repository-set-save-duplicates")).toBeTruthy();
expect(screen.queryByTestId("repository-set-save-excluded")).toBeNull();
fireEvent.change(screen.getByTestId(NAME_INPUT), { target: { value: SET_NAME } });
fireEvent.click(screen.getByTestId(SUBMIT));
await waitFor(() => expect(mockCreateRepositorySet).toHaveBeenCalledTimes(1));
expect(mockCreateRepositorySet.mock.calls[0][1].repositories).toEqual([
{ repositoryId: REPO_WEB, baseBranch: "main" },
{ repositoryId: REPO_GATEWAY, baseBranch: "develop" },
]);
expect(rows).toEqual(originalRows);
});
});
describe("SaveRepositorySetDialog", () => {
it("creates a set from the form's selected repositories in row order", async () => {
renderDialog();
@@ -74,8 +120,10 @@ describe("SaveRepositorySetDialog", () => {
expect(mockCreateRepositorySet).toHaveBeenCalledWith("ws-1", {
name: SET_NAME,
description: "",
// Branches are deliberately not sent: a set holds repositories only.
repositoryIds: [REPO_WEB, REPO_GATEWAY],
repositories: [
{ repositoryId: REPO_WEB, baseBranch: "main" },
{ repositoryId: REPO_GATEWAY, baseBranch: "develop" },
],
});
});
@@ -153,7 +201,9 @@ describe("SaveRepositorySetDialog", () => {
fireEvent.click(screen.getByTestId(SUBMIT));
await waitFor(() => expect(mockCreateRepositorySet).toHaveBeenCalledTimes(1));
expect(mockCreateRepositorySet.mock.calls[0][1].repositoryIds).toEqual([REPO_GATEWAY]);
expect(mockCreateRepositorySet.mock.calls[0][1].repositories).toEqual([
{ repositoryId: REPO_GATEWAY, baseBranch: "main" },
]);
// The user is told which rows could not be included.
expect(screen.queryByTestId("repository-set-save-excluded")).not.toBeNull();
});
@@ -17,8 +17,12 @@ import { Textarea } from "@kandev/ui/textarea";
import { createRepositorySet } from "@/lib/api";
import { ApiError } from "@/lib/api/client";
import { useAppStore } from "@/components/state-provider";
import type { Repository } from "@/lib/types/http";
import type { TaskRepoRow } from "@/components/task-create-dialog-types";
import { selectedRepositoryIdsForSet } from "@/components/task-create-dialog-repository-sets";
import {
selectedRepositoryIdsForSet,
selectedRepositoryMembersForSet,
} from "@/components/task-create-dialog-repository-sets";
type SaveRepositorySetDialogProps = {
open: boolean;
@@ -26,6 +30,12 @@ type SaveRepositorySetDialogProps = {
workspaceId: string;
/** The picker's current rows; only workspace repository rows can be saved. */
rows: TaskRepoRow[];
/** Workspace defaults are needed to capture the effective local base. */
repositories?: Repository[];
/** Local execution keeps the checkout branch separate from the base. */
isLocalExecutor?: boolean;
/** Fresh-branch mode uses the selected row branch as the fork base. */
freshBranchEnabled?: boolean;
};
/**
@@ -33,14 +43,17 @@ type SaveRepositorySetDialogProps = {
*
* This is the definition path that does not make the user leave a task they are
* in the middle of creating: it creates the set and leaves the draft untouched.
* Branches are not saved, matching the model - a set holds repositories, and the
* branch is chosen per task.
* Base choices are saved as part of each ordered member. Checkout state for a
* local executor is intentionally not part of the set.
*/
export function SaveRepositorySetDialog({
open,
onOpenChange,
workspaceId,
rows,
repositories,
isLocalExecutor = false,
freshBranchEnabled = false,
}: SaveRepositorySetDialogProps) {
const { t } = useTranslation();
const upsertRepositorySet = useAppStore((state) => state.upsertRepositorySet);
@@ -50,9 +63,14 @@ export function SaveRepositorySetDialog({
const [saving, setSaving] = useState(false);
const repositoryIds = useMemo(() => selectedRepositoryIdsForSet(rows), [rows]);
const repositoryMembers = useMemo(
() => selectedRepositoryMembersForSet(rows, repositories, isLocalExecutor, freshBranchEnabled),
[rows, repositories, isLocalExecutor, freshBranchEnabled],
);
// A row that names a discovered local path, a remote URL, or nothing at all is
// not a workspace repository, so it cannot be a member.
const excludedRowCount = rows.filter((row) => !row.repositoryId).length;
const duplicateRowCount = rows.length - excludedRowCount - repositoryIds.length;
const handleSubmit = async () => {
const trimmed = name.trim();
@@ -63,7 +81,7 @@ export function SaveRepositorySetDialog({
const created = await createRepositorySet(workspaceId, {
name: trimmed,
description: description.trim(),
repositoryIds,
repositories: repositoryMembers,
});
upsertRepositorySet(workspaceId, created);
setName("");
@@ -93,6 +111,7 @@ export function SaveRepositorySetDialog({
description={description}
memberCount={repositoryIds.length}
excludedRowCount={excludedRowCount}
duplicateRowCount={duplicateRowCount}
error={error}
onNameChange={setName}
onDescriptionChange={setDescription}
@@ -121,6 +140,7 @@ type SaveRepositorySetFieldsProps = {
description: string;
memberCount: number;
excludedRowCount: number;
duplicateRowCount: number;
error: string | null;
onNameChange: (value: string) => void;
onDescriptionChange: (value: string) => void;
@@ -132,6 +152,7 @@ function SaveRepositorySetFields({
description,
memberCount,
excludedRowCount,
duplicateRowCount,
error,
onNameChange,
onDescriptionChange,
@@ -166,6 +187,11 @@ function SaveRepositorySetFields({
{t("task:repositorySetsSaveExcludedRows", { count: excludedRowCount })}
</p>
) : null}
{duplicateRowCount > 0 ? (
<p className="text-xs text-muted-foreground" data-testid="repository-set-save-duplicates">
{t("task:repositorySetsSaveDuplicateRows", { count: duplicateRowCount })}
</p>
) : null}
{error ? (
<p className="text-xs text-destructive" data-testid="repository-set-save-error">
{error}
@@ -5,6 +5,7 @@ import { repositoryId, workspaceId } from "@/lib/types/ids";
import {
applyRepositorySet,
selectedRepositoryIdsForSet,
selectedRepositoryMembersForSet,
} from "@/components/task-create-dialog-repository-sets";
import type { TaskRepoRow } from "@/components/task-create-dialog-types";
@@ -18,13 +19,21 @@ const REPO_ORDERS = "repo-orders";
const ROW_0 = "row-0";
const AVAILABLE = [repository(REPO_WEB), repository(REPO_GATEWAY), repository(REPO_ORDERS)];
function repositorySet(ids: string[], name = "Full-stack"): RepositorySet {
function repositorySet(
ids: string[],
name = "Full-stack",
bases: Record<string, string> = {},
): RepositorySet {
return {
id: "set-1",
workspace_id: workspaceId("ws-1"),
name,
description: "",
repositories: ids.map((id, position) => ({ repository_id: repositoryId(id), position })),
repositories: ids.map((id, position) => ({
repository_id: repositoryId(id),
position,
base_branch: bases[id] ?? "",
})),
created_at: "2026-08-17T09:00:00Z",
updated_at: "2026-08-17T09:00:00Z",
};
@@ -60,6 +69,30 @@ describe("applyRepositorySet", () => {
expect(result.rows[0].branch).toBe("");
});
it("copies a saved base into new row state without using it as the row checkout", () => {
const result = applyRepositorySet({
rows: [],
set: repositorySet([REPO_WEB], "Full-stack", { [REPO_WEB]: "develop" }),
repositories: AVAILABLE,
});
expect(result.rows[0]).toMatchObject({
repositoryId: REPO_WEB,
branch: "",
baseBranch: "develop",
});
});
it("keeps an empty saved base on normal task defaulting", () => {
const result = applyRepositorySet({
rows: [],
set: repositorySet([REPO_WEB]),
repositories: AVAILABLE,
});
expect(result.rows[0]).not.toHaveProperty("baseBranch");
});
it("consumes a single blank placeholder row instead of leaving it behind", () => {
const result = applyRepositorySet({
rows: [row(ROW_0)],
@@ -234,3 +267,46 @@ describe("selectedRepositoryIdsForSet", () => {
expect(ids).toEqual([REPO_WEB]);
});
});
describe("selectedRepositoryMembersForSet", () => {
// Reviewer-requested coverage of the existing first-row-wins contract.
it.each([
{ isLocalExecutor: false, freshBranchEnabled: false, expected: "saved-base" },
{ isLocalExecutor: true, freshBranchEnabled: false, expected: "saved-base" },
{ isLocalExecutor: true, freshBranchEnabled: true, expected: "develop" },
])("keeps the first duplicate's effective base: %o", (mode) => {
const rows = [
{ ...row(ROW_0, REPO_WEB, "develop"), baseBranch: "saved-base" },
row("row-1", REPO_WEB, "feature/x"),
row("row-2", REPO_GATEWAY, "develop"),
];
expect(
selectedRepositoryMembersForSet(rows, [], mode.isLocalExecutor, mode.freshBranchEnabled),
).toEqual([
{ repositoryId: REPO_WEB, baseBranch: mode.expected },
{
repositoryId: REPO_GATEWAY,
baseBranch: mode.isLocalExecutor && !mode.freshBranchEnabled ? "" : "develop",
},
]);
});
it("captures the selected fork base while fresh-branch mode is enabled", () => {
const repository = {
...AVAILABLE[0],
default_branch: "main",
} as Repository;
const rows: TaskRepoRow[] = [
{
key: ROW_0,
repositoryId: REPO_WEB,
branch: "develop",
baseBranch: "main",
},
];
expect(selectedRepositoryMembersForSet(rows, [repository], true, true)).toEqual([
{ repositoryId: REPO_WEB, baseBranch: "develop" },
]);
});
});
@@ -12,8 +12,9 @@ import type { TaskRepoRow } from "@/components/task-create-dialog-types";
* - a row the user configured is never discarded or reordered;
* - a member that no longer resolves to a live workspace repository is skipped
* and counted, so the UI can say how many were dropped and why;
* - branches are left empty for the dialog's per-row branch defaulting, because
* branch choice belongs to the task rather than to the set.
* - a saved base is copied to baseBranch on the new row. The ordinary branch
* field stays empty so a local executor can still autoselect the current
* checkout branch without using the saved base as checkout state.
*
* Kept as a pure function so those rules are testable without rendering the
* dialog.
@@ -63,7 +64,13 @@ export function applyRepositorySet({
continue;
}
present.add(id);
added.push({ key: nextAppliedRowKey(takenKeys), repositoryId: id, branch: "" });
const savedBase = member.base_branch || undefined;
added.push({
key: nextAppliedRowKey(takenKeys),
repositoryId: id,
branch: "",
...(savedBase ? { baseBranch: savedBase } : {}),
});
}
return {
@@ -128,3 +135,28 @@ export function selectedRepositoryIdsForSet(rows: TaskRepoRow[]): string[] {
}
return ids;
}
/**
* Projects the current task rows into the ordered member shape used by the
* repository-set API. Local rows use the repository default as their effective
* base because their row branch is the checkout branch on disk.
*/
export function selectedRepositoryMembersForSet(
rows: TaskRepoRow[],
repositories: Repository[] = [],
isLocalExecutor = false,
freshBranchEnabled = false,
): Array<{ repositoryId: string; baseBranch: string }> {
const byId = new Map(repositories.map((repository) => [repository.id as string, repository]));
const seen = new Set<string>();
return rows.flatMap((row) => {
const repositoryId = row.repositoryId;
if (!repositoryId || seen.has(repositoryId)) return [];
seen.add(repositoryId);
const repositoryDefault = byId.get(repositoryId)?.default_branch ?? "";
const baseBranch = freshBranchEnabled
? row.branch
: row.baseBranch || (isLocalExecutor ? repositoryDefault : row.branch) || "";
return [{ repositoryId, baseBranch }];
});
}
@@ -2,6 +2,7 @@ import { renderHook } from "@testing-library/react";
import type { FormEvent } from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { TaskCreateDialogProps } from "./task-create-dialog";
import type { RepositoryBranchesState } from "@/lib/state/slices/workspace/types";
const mocks = vi.hoisted(() => ({
agentGeneratedTaskTitles: false,
@@ -146,7 +147,7 @@ vi.mock("@/components/task-create-dialog-state", () => ({
useSessionRepoName: () => null,
}));
import { useTaskCreateDialogSetup } from "./task-create-dialog-setup";
import { hasUnavailableSavedBase, useTaskCreateDialogSetup } from "./task-create-dialog-setup";
const props: TaskCreateDialogProps = {
open: true,
@@ -207,3 +208,25 @@ describe("compatibility submit guard", () => {
},
);
});
it("validates saved bases against qualified branch option values", () => {
const repositoryBranches = {
itemsByRepositoryId: {
"repo-1": [{ name: "main", type: "remote", remote: "origin" }],
},
loadedByRepositoryId: { "repo-1": true },
} as unknown as RepositoryBranchesState;
expect(
hasUnavailableSavedBase(
[{ key: "r0", repositoryId: "repo-1", branch: "", baseBranch: "main" }],
repositoryBranches,
),
).toBe(true);
expect(
hasUnavailableSavedBase(
[{ key: "r0", repositoryId: "repo-1", branch: "", baseBranch: "origin/main" }],
repositoryBranches,
),
).toBe(false);
});
+121 -21
View File
@@ -4,6 +4,8 @@ import { FormEvent, useCallback, useState } from "react";
import type { JiraTicket } from "@/lib/types/jira";
import type { LinearIssue } from "@/lib/types/linear";
import type { Repository } from "@/lib/types/http";
import type { RepositoryBranchesState } from "@/lib/state/slices/workspace/types";
import { branchOptionValue } from "@/components/branch-picker-options";
import { SHORTCUTS } from "@/lib/keyboard/constants";
import { useIsUtilityConfigured } from "@/hooks/use-is-utility-configured";
import { useKeyboardShortcutHandler } from "@/hooks/use-keyboard-shortcut";
@@ -126,10 +128,6 @@ function useEditDialogDependencies(
});
}
function isFreshBranchAvailable(fs: DialogFormState, isLocalExecutor: boolean): boolean {
return !fs.useRemote && isLocalExecutor && fs.repositories.length === 1;
}
type SubmitWiringArgs = {
props: TaskCreateDialogProps;
fs: ReturnType<typeof useDialogFormState>;
@@ -230,6 +228,65 @@ function resolveSingleRowLocalPath(fs: DialogFormState, repositories: Repository
return "";
}
function resolveDialogMode(
mode: TaskCreateDialogProps["mode"],
editingTask: TaskCreateDialogProps["editingTask"],
) {
const isSessionMode = mode === "session";
const isEditMode = mode === "edit";
return {
isSessionMode,
isEditMode,
isTaskStarted: computeIsTaskStarted(isEditMode, editingTask),
};
}
function canUseFreshBranch(fs: DialogFormState, isLocalExecutor: boolean): boolean {
return !fs.useRemote && isLocalExecutor && fs.repositories.length === 1;
}
export function hasUnavailableSavedBase(
rows: DialogFormState["repositories"],
repositoryBranches: RepositoryBranchesState,
): boolean {
return rows.some((row) => {
if (!row.repositoryId || !row.baseBranch) return false;
if (!repositoryBranches.loadedByRepositoryId[row.repositoryId]) return false;
const branches = repositoryBranches.itemsByRepositoryId[row.repositoryId] ?? [];
return !branches.some((branch) => branchOptionValue(branch) === row.baseBranch);
});
}
function hasPendingSavedBaseValidation(
rows: DialogFormState["repositories"],
repositoryBranches: RepositoryBranchesState,
): boolean {
return rows.some(
(row) =>
Boolean(row.repositoryId && row.baseBranch) &&
!repositoryBranches.loadedByRepositoryId[row.repositoryId as string],
);
}
function savedBaseSubmitBlockedReason(
hasPendingValidation: boolean,
hasUnavailableBase: boolean,
): string | null {
if (hasPendingValidation) return t("task:loadingBranches2");
if (hasUnavailableBase) return t("task:repositorySetBaseUnavailable");
return null;
}
function savedBaseSubmitBlockedReasonForRows(
rows: DialogFormState["repositories"],
repositoryBranches: RepositoryBranchesState,
): string | null {
return savedBaseSubmitBlockedReason(
hasPendingSavedBaseValidation(rows, repositoryBranches),
hasUnavailableSavedBase(rows, repositoryBranches),
);
}
function useDialogSetupData(
props: TaskCreateDialogProps,
fs: ReturnType<typeof useDialogFormState>,
@@ -238,6 +295,7 @@ function useDialogSetupData(
const { toast } = useToast();
const storeApi = useAppStoreApi();
const upsertWorkspaceRepository = useAppStore((state) => state.upsertRepository);
const repositoryBranches = useAppStore((state) => state.repositoryBranches);
const setRepositoryBranchPolicies = useAppStore((state) => state.setRepositoryBranchPolicies);
const setRepositoryBranchPoliciesLoading = useAppStore(
(state) => state.setRepositoryBranchPoliciesLoading,
@@ -290,6 +348,10 @@ function useDialogSetupData(
executors,
upsertWorkspaceRepository,
});
const savedBaseSubmitBlockedReasonValue = savedBaseSubmitBlockedReasonForRows(
fs.repositories,
repositoryBranches,
);
const refreshBranchPolicies = useCallback(async () => {
const repositoryIds = [
...new Set(
@@ -323,6 +385,7 @@ function useDialogSetupData(
handlers,
refreshBranchPolicies,
repositoryLocalPath: resolveSingleRowLocalPath(fs, repositories),
savedBaseSubmitBlockedReason: savedBaseSubmitBlockedReasonValue,
};
}
@@ -339,9 +402,7 @@ export function useTaskCreateDialogSetup(
editingTask,
initialValues,
} = resolvedProps;
const isSessionMode = mode === "session";
const isEditMode = mode === "edit";
const isTaskStarted = computeIsTaskStarted(isEditMode, editingTask);
const { isSessionMode, isEditMode, isTaskStarted } = resolveDialogMode(mode, editingTask);
const agentGeneratedTaskTitles = useAppStore(
(state) => state.userSettings.agentGeneratedTaskTitles,
);
@@ -361,7 +422,14 @@ export function useTaskCreateDialogSetup(
);
const sessionRepoName = useSessionRepoName(isSessionMode);
const data = useDialogSetupData(resolvedProps, fs);
const { computed, handlers, repositoryLocalPath, refreshBranchPolicies } = data;
const {
repositories,
userSettingsLoaded,
computed,
repositoryLocalPath,
refreshBranchPolicies,
savedBaseSubmitBlockedReason,
} = data;
const submitHandlers = useSubmitHandlersWiring({
props: resolvedProps,
fs,
@@ -377,7 +445,7 @@ export function useTaskCreateDialogSetup(
});
const guardedHandleSubmit = useGuardedSubmit(
submitHandlers.handleSubmit,
resolvedProps.submitBlockedReason,
resolvedProps.submitBlockedReason ?? savedBaseSubmitBlockedReason,
!isTaskStarted && computed.noCompatibleAgent,
);
const handleKeyDown = useKeyboardShortcutHandler(SHORTCUTS.SUBMIT, (event) => {
@@ -386,16 +454,14 @@ export function useTaskCreateDialogSetup(
const enhance = useEnhanceForDialog(fs, resolvedProps.taskId, resolvedProps.open);
const handleJiraImport = useJiraImportHandler(fs, data.handlers.handleTaskNameChange);
const handleLinearImport = useLinearImportHandler(fs, data.handlers.handleTaskNameChange);
const freshBranchAvailable = isFreshBranchAvailable(fs, computed.isLocalExecutor);
const repositorySets = useRepositorySetsForDialog({
workspaceId: resolvedProps.workspaceId ?? null,
open: resolvedProps.open,
rows: fs.repositories,
repositories: data.repositories,
setRepositories: fs.setRepositories,
setRepositoriesDirty: fs.setRepositoriesDirty,
userSettingsLoaded: data.userSettingsLoaded,
});
const freshBranchAvailable = canUseFreshBranch(fs, computed.isLocalExecutor);
const repositorySets = useDialogRepositorySets(
resolvedProps,
fs,
repositories,
computed,
userSettingsLoaded,
);
return {
...data,
fs,
@@ -406,7 +472,6 @@ export function useTaskCreateDialogSetup(
isTaskStarted,
sessionRepoName,
computed,
handlers,
submitHandlers,
handleKeyDown,
freshBranchAvailable,
@@ -416,9 +481,30 @@ export function useTaskCreateDialogSetup(
handleJiraImport,
handleLinearImport,
editDependencies,
savedBaseSubmitBlockedReason,
};
}
function useDialogRepositorySets(
resolvedProps: TaskCreateDialogProps,
fs: DialogFormState,
repositories: Repository[],
computed: ReturnType<typeof useTaskCreateDialogData>["computed"],
userSettingsLoaded: boolean,
) {
return useRepositorySetsForDialog({
workspaceId: resolvedProps.workspaceId ?? null,
open: resolvedProps.open,
rows: fs.repositories,
repositories,
setRepositories: fs.setRepositories,
setRepositoriesDirty: fs.setRepositoriesDirty,
userSettingsLoaded,
isLocalExecutor: computed.isLocalExecutor,
freshBranchEnabled: fs.freshBranchEnabled,
});
}
type RepositorySetsForDialogArgs = {
workspaceId: string | null;
open: boolean;
@@ -427,6 +513,8 @@ type RepositorySetsForDialogArgs = {
setRepositories: DialogFormState["setRepositories"];
setRepositoriesDirty: DialogFormState["setRepositoriesDirty"];
userSettingsLoaded: boolean;
isLocalExecutor: boolean;
freshBranchEnabled: boolean;
};
/**
@@ -445,6 +533,8 @@ function useRepositorySetsForDialog({
setRepositories,
setRepositoriesDirty,
userSettingsLoaded,
isLocalExecutor,
freshBranchEnabled,
}: RepositorySetsForDialogArgs) {
const { sets } = useRepositorySets(workspaceId, open);
const onApply = useApplyRepositorySet({
@@ -462,7 +552,17 @@ function useRepositorySetsForDialog({
sets,
onApply,
save:
canSave && workspaceId ? { workspaceId, rows, open: saveOpen, setOpen: setSaveOpen } : null,
canSave && workspaceId
? {
workspaceId,
rows,
repositories,
isLocalExecutor,
freshBranchEnabled,
open: saveOpen,
setOpen: setSaveOpen,
}
: null,
};
}
@@ -119,6 +119,8 @@ export type TaskRepoRow = {
/** On-machine repo path, when the user picked from discovered repos. */
localPath?: string;
branch: string;
/** Explicit effective base copied from a repository set or chosen in the form. */
baseBranch?: string;
/** Saved repository policy selected for this row. */
branchPolicyId?: string;
};
@@ -636,6 +638,9 @@ export type DialogFormBodyProps = {
save?: {
workspaceId: string;
rows: TaskRepoRow[];
repositories: Repository[];
isLocalExecutor: boolean;
freshBranchEnabled: boolean;
open: boolean;
setOpen: (open: boolean) => void;
} | null;
@@ -155,6 +155,49 @@ describe("WorkspaceRepoChips branch policy preview", () => {
});
});
describe("WorkspaceRepoChips saved base display", () => {
it("shows the saved base separately from a local executor checkout branch", () => {
renderChips({
rows: [
row({
key: "r0",
repositoryId: FRONTEND_ID,
branch: "feature/task",
baseBranch: "develop",
}),
],
isLocalExecutor: true,
});
expect(screen.getByTestId("repo-chip-base-branch").textContent).toContain("develop");
expect(screen.getByTestId("branch-chip-trigger").textContent).toContain("feature/task");
});
it("edits the local executor saved base without changing checkout state", () => {
const onRowBaseBranchChange = vi.fn();
const onRowBranchChange = vi.fn();
renderChips({
rows: [
row({
key: "r0",
repositoryId: FRONTEND_ID,
branch: "feature/task",
baseBranch: "develop",
}),
],
isLocalExecutor: true,
onRowBaseBranchChange,
onRowBranchChange,
});
fireEvent.click(screen.getByTestId("repo-chip-base-branch"));
fireEvent.click(screen.getByRole("option", { name: /Task default/ }));
expect(onRowBaseBranchChange).toHaveBeenCalledWith("r0", "");
expect(onRowBranchChange).not.toHaveBeenCalledWith("r0", "");
});
});
describe("WorkspaceRepoChips workspace markers", () => {
it("marks another task row's workspace repository while keeping it selectable", () => {
const onRowRepositoryChange = vi.fn();
@@ -1,15 +1,19 @@
"use client";
import { useMemo } from "react";
import { IconPlus, IconX, IconCheck } from "@tabler/icons-react";
import { Badge } from "@kandev/ui/badge";
import { IconX } from "@tabler/icons-react";
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
import { useBranches, type BranchSource } from "@/hooks/domains/workspace/use-repository-branches";
import type { LocalRepository, Repository, RepositoryBranchPolicy } from "@/lib/types/http";
import type { TaskRepoRow } from "@/components/task-create-dialog-types";
import { cn, formatUserHomePath } from "@/lib/utils";
import { type PillOption } from "@/components/task-create-dialog-pill";
import { branchToOption, sortBranches } from "@/components/branch-picker-options";
import { buildRepoBaseBranchData } from "@/components/task-create-dialog-repo-base-branch";
import {
buildRepoOptions,
computeRepoChipDisplay,
normalizeRepoPath,
} from "@/components/task-create-dialog-repo-chip-utils";
import {
computeBranchIntent,
type BranchIntent,
@@ -17,12 +21,13 @@ import {
import { useRepoBranchAutoselect } from "@/components/task-create-dialog-repo-branch-autoselect";
import { useRepositoryBranchPolicies } from "@/hooks/domains/workspace/use-repository-branch-policies";
import {
RepoChipBaseBranchPill,
RepoChipBranchPill,
RepoChipRepositoryPill,
useRepoChipBranchPicker,
} from "@/components/task-create-dialog-repo-chip-parts";
import { AddRepositoryButton } from "@/components/task-create-dialog-add-repository-button";
import { useTranslation } from "react-i18next";
import { t } from "@/lib/i18n";
import { RepositoryDiscoveryControls } from "@/components/repository-discovery-controls";
type WorkspaceRepoChipsProps = {
@@ -47,6 +52,7 @@ type WorkspaceRepoChipsProps = {
onRemove: (key: string) => void;
onRowRepositoryChange: (key: string, value: string) => void;
onRowBranchChange: (key: string, value: string) => void;
onRowBaseBranchChange?: (key: string, value: string) => void;
onRowPolicyChange?: (key: string, policyId: string, baseBranch: string) => void;
onPolicySelected?: () => void;
onCreateRepository?: (key: string) => void;
@@ -83,6 +89,7 @@ export function WorkspaceRepoChips({
onRemove,
onRowRepositoryChange,
onRowBranchChange,
onRowBaseBranchChange,
onRowPolicyChange,
onPolicySelected,
onCreateRepository,
@@ -91,7 +98,6 @@ export function WorkspaceRepoChips({
lastUsedBranch,
userSettingsLoaded,
}: WorkspaceRepoChipsProps) {
const { t } = useTranslation();
return (
<>
{rows.map((row) => (
@@ -114,15 +120,19 @@ export function WorkspaceRepoChips({
preferredDefaultBranchLoading={isLocalExecutor ? currentLocalBranchLoading : false}
lastUsedBranch={lastUsedBranch}
userSettingsLoaded={userSettingsLoaded}
isLocalExecutor={!!isLocalExecutor}
branchValue={isLocalExecutor ? row.branch : row.baseBranch || row.branch}
savedBaseBranch={row.baseBranch}
branchIntent={computeBranchIntent({
isLocalExecutor: !!isLocalExecutor,
rowBranch: row.branch,
rowBranch: isLocalExecutor ? row.branch : row.baseBranch || row.branch,
currentLocalBranch: currentLocalBranch ?? "",
freshBranchEnabled: !!freshBranchEnabled,
})}
branchPolicyDisabledReason={branchPolicyDisabledReason}
onRepositoryChange={(value) => onRowRepositoryChange(row.key, value)}
onBranchChange={(value) => onRowBranchChange(row.key, value)}
onBaseBranchChange={(value) => onRowBaseBranchChange?.(row.key, value)}
onPolicyChange={
onRowPolicyChange
? (policyId, baseBranch) => onRowPolicyChange(row.key, policyId, baseBranch)
@@ -140,30 +150,12 @@ export function WorkspaceRepoChips({
/>
))}
{freshBranchToggle}
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex" tabIndex={canAddMore ? undefined : 0}>
<button
type="button"
onClick={onAdd}
disabled={!canAddMore}
aria-label={t("task:addRepository")}
data-testid="add-repository"
className={cn(
"inline-flex items-center justify-center gap-1.5 rounded-md text-muted-foreground",
addLabel ? "h-11 px-2 text-xs md:h-9" : "h-11 w-11 md:h-7 md:w-7",
canAddMore
? "hover:bg-muted hover:text-foreground cursor-pointer"
: "opacity-40 cursor-not-allowed",
)}
>
<IconPlus className="h-3.5 w-3.5" />
{addLabel ? <span>{addLabel}</span> : null}
</button>
</span>
</TooltipTrigger>
<TooltipContent>{addHint ?? t("task:addAnotherRepository")}</TooltipContent>
</Tooltip>
<AddRepositoryButton
canAddMore={canAddMore}
addHint={addHint}
addLabel={addLabel}
onAdd={onAdd}
/>
</>
);
}
@@ -246,6 +238,9 @@ type RepoChipProps = {
preferredDefaultBranch?: string;
lastUsedBranch?: string | null;
userSettingsLoaded?: boolean;
isLocalExecutor?: boolean;
branchValue?: string;
savedBaseBranch?: string;
/**
* True while preferredDefaultBranch is being resolved. Renders a
* "Loading branch…" placeholder so the chip doesn't briefly show an empty
@@ -263,6 +258,7 @@ type RepoChipProps = {
branchIntent?: BranchIntent;
onRepositoryChange: (value: string) => void;
onBranchChange: (value: string) => void;
onBaseBranchChange?: (value: string) => void;
onPolicyChange?: (policyId: string, baseBranch: string) => void;
onPolicySelected?: () => void;
branchPolicyDisabledReason?: string;
@@ -278,6 +274,7 @@ function useRepoChipBranchData({
row,
workspaceId,
onBranchChange,
branchValue,
preferredDefaultBranch,
preferredDefaultBranchLoading,
lastUsedBranch,
@@ -287,10 +284,13 @@ function useRepoChipBranchData({
| "row"
| "workspaceId"
| "onBranchChange"
| "branchValue"
| "preferredDefaultBranch"
| "preferredDefaultBranchLoading"
| "lastUsedBranch"
| "userSettingsLoaded"
| "savedBaseBranch"
| "isLocalExecutor"
>) {
const branchSource = useMemo<BranchSource | null>(() => {
if (!workspaceId) return null;
@@ -306,19 +306,20 @@ function useRepoChipBranchData({
branches,
isLoading: branchesLoading,
refresh: refreshBranches,
isLoaded: branchesLoaded,
} = useBranches(branchSource, !!branchSource);
useRepoBranchAutoselect({
branchSource,
branchesLoading,
branches,
rowBranch: row.branch,
rowBranch: branchValue,
onBranchChange,
preferredDefaultBranch,
preferredDefaultBranchLoading,
lastUsedBranch,
userSettingsLoaded,
});
return { branches, branchesLoading, refreshBranches };
return { branches, branchesLoading, branchesLoaded, refreshBranches };
}
function useRepoChipData({
@@ -333,6 +334,9 @@ function useRepoChipData({
preferredDefaultBranchLoading,
lastUsedBranch,
userSettingsLoaded,
branchValue,
savedBaseBranch,
isLocalExecutor,
}: Pick<
RepoChipProps,
| "row"
@@ -346,6 +350,9 @@ function useRepoChipData({
| "preferredDefaultBranchLoading"
| "lastUsedBranch"
| "userSettingsLoaded"
| "branchValue"
| "savedBaseBranch"
| "isLocalExecutor"
>) {
const filteredRepos = useMemo(
() => repositories.filter((r) => !excludedRepoIds.has(r.id) || r.id === row.repositoryId),
@@ -364,10 +371,11 @@ function useRepoChipData({
(!excludedRepoIds.has(r.path) || r.path === row.localPath),
);
}, [filteredRepos, discoveredRepositories, excludedRepoIds, row.localPath]);
const { branches, branchesLoading, refreshBranches } = useRepoChipBranchData({
const { branches, branchesLoading, branchesLoaded, refreshBranches } = useRepoChipBranchData({
row,
workspaceId,
onBranchChange,
branchValue,
preferredDefaultBranch,
preferredDefaultBranchLoading,
lastUsedBranch,
@@ -375,50 +383,34 @@ function useRepoChipData({
});
const repoOptions: PillOption[] = useMemo(
() => [
...filteredRepos.map((r) => ({
value: r.id,
label: r.name,
keywords: [r.name, r.local_path, formatUserHomePath(r.local_path)].filter(
(s): s is string => !!s,
),
renderLabel: () =>
renderWorkspaceRepoOption(
r,
selectedElsewhere.has(repoIdIdentity(r.id)) ||
(!!r.local_path && selectedElsewhere.has(repoPathIdentity(r.local_path))),
),
})),
...filteredDiscovered.map((r) => ({
value: r.path,
label: leafSegment(r.path),
keywords: [r.path, formatUserHomePath(r.path)],
renderLabel: () =>
renderDiscoveredRepoOption(r.path, selectedElsewhere.has(repoPathIdentity(r.path))),
})),
],
() => buildRepoOptions(filteredRepos, filteredDiscovered, selectedElsewhere),
[filteredRepos, filteredDiscovered, selectedElsewhere],
);
const branchOptions: PillOption[] = useMemo(
() => sortBranches(branches).map(branchToOption),
[branches],
const { baseBranchOptions, defaultBranch } = useMemo(
() =>
buildRepoBaseBranchData({
branches,
branchesLoaded,
savedBaseBranch,
row,
repositories,
discoveredRepositories,
}),
[branches, branchesLoaded, discoveredRepositories, repositories, row, savedBaseBranch],
);
return { repoOptions, branchOptions, branches, branchesLoading, refreshBranches };
}
function computeRepoChipDisplay(
row: TaskRepoRow,
repositories: Repository[],
discoveredRepositories: LocalRepository[],
) {
const workspaceRepo = repositories.find((r) => r.id === row.repositoryId);
const discoveredRepo = discoveredRepositories.find((r) => r.path === row.localPath);
const repoLabel = workspaceRepo?.name ?? (discoveredRepo ? leafSegment(discoveredRepo.path) : "");
const repoPath = workspaceRepo?.local_path || discoveredRepo?.path || "";
const repoTooltip = repoPath
? t("task:repositoryWithPath", { path: formatUserHomePath(repoPath) })
: t("task:repository2");
return { repoLabel, repoTooltip };
const branchOptions: PillOption[] = useMemo(() => {
if (!isLocalExecutor && savedBaseBranch) return baseBranchOptions;
return sortBranches(branches).map(branchToOption);
}, [baseBranchOptions, branches, isLocalExecutor, savedBaseBranch]);
return {
repoOptions,
branchOptions,
baseBranchOptions,
defaultBranch,
branches,
branchesLoading,
refreshBranches,
};
}
type RepoChipData = ReturnType<typeof useRepoChipData>;
@@ -437,6 +429,9 @@ function RepoChip(props: RepoChipProps) {
preferredDefaultBranchLoading,
lastUsedBranch,
userSettingsLoaded,
isLocalExecutor,
branchValue,
savedBaseBranch,
} = props;
const data = useRepoChipData({
row,
@@ -446,6 +441,9 @@ function RepoChip(props: RepoChipProps) {
excludedRepoIds,
selectedElsewhere,
onBranchChange,
isLocalExecutor,
branchValue: branchValue ?? row.branch,
savedBaseBranch,
preferredDefaultBranch,
preferredDefaultBranchLoading,
lastUsedBranch,
@@ -473,10 +471,14 @@ function RepoChipContent({
repositories,
discoveredRepositories,
branchLocked,
branchValue,
isLocalExecutor,
savedBaseBranch,
preferredDefaultBranchLoading,
branchIntent,
onRepositoryChange,
onBranchChange,
onBaseBranchChange,
onPolicyChange,
onPolicySelected,
branchPolicyDisabledReason,
@@ -487,7 +489,15 @@ function RepoChipContent({
showDiscoveryControls,
workspaceId,
}: RepoChipProps & { data: RepoChipData; branchPolicies: RepositoryBranchPolicy[] }) {
const { repoOptions, branchOptions, branches, branchesLoading, refreshBranches } = data;
const {
repoOptions,
branchOptions,
baseBranchOptions,
defaultBranch,
branches,
branchesLoading,
refreshBranches,
} = data;
const { repoLabel, repoTooltip } = computeRepoChipDisplay(
row,
repositories,
@@ -499,6 +509,7 @@ function RepoChipContent({
branches,
branchOptions,
branchesLoading,
branchValue: branchValue ?? row.branch,
preferredDefaultBranchLoading,
policyDisabledReason: branchPolicyDisabledReason,
onBranchChange,
@@ -534,6 +545,17 @@ function RepoChipContent({
branchesLoading={branchesLoading}
refreshBranches={refreshBranches}
/>
{isLocalExecutor && savedBaseBranch ? (
<RepoChipBaseBranchPill
options={baseBranchOptions}
value={savedBaseBranch}
defaultBranch={defaultBranch}
hasRepo={!!(row.repositoryId || row.localPath)}
branchesLoading={branchesLoading}
onSelect={onBaseBranchChange ?? (() => undefined)}
refreshBranches={refreshBranches}
/>
) : null}
<RepoChipRemoveButton onRemove={onRemove} />
</span>
);
@@ -558,61 +580,3 @@ function RepoChipRemoveButton({ onRemove }: { onRemove: () => void }) {
</Tooltip>
);
}
function normalizeRepoPath(path: string): string {
return path.replace(/\\/g, "/").replace(/\/+$/g, "");
}
function renderWorkspaceRepoOption(repo: Repository, alreadyAdded: boolean) {
const display = repo.local_path ? formatUserHomePath(repo.local_path) : "";
return (
<span
className="flex min-w-0 flex-1 items-center gap-2 overflow-hidden"
title={display || repo.name}
>
<span className="flex min-w-0 flex-1 flex-col overflow-hidden">
<span className="truncate">{repo.name}</span>
{display ? (
<span className="truncate text-[11px] text-muted-foreground">{display}</span>
) : null}
</span>
{alreadyAdded ? <AlreadyAddedMarker /> : null}
</span>
);
}
function renderDiscoveredRepoOption(path: string, alreadyAdded: boolean) {
const display = formatUserHomePath(path);
return (
<span className="flex min-w-0 flex-1 items-center gap-2 overflow-hidden" title={display}>
<span className="flex min-w-0 flex-1 flex-col overflow-hidden">
<span className="truncate">{leafSegment(path)}</span>
<span className="truncate text-[11px] text-muted-foreground">{display}</span>
</span>
<Badge variant="outline" className="text-[10px] text-muted-foreground shrink-0">
{t("task:onDisk")}
</Badge>
{alreadyAdded ? <AlreadyAddedMarker /> : null}
</span>
);
}
function AlreadyAddedMarker() {
const { t } = useTranslation();
return (
<span
role="img"
aria-label={t("task:alreadyAdded")}
data-testid="already-added-repository-marker"
className="shrink-0 text-primary"
>
<IconCheck aria-hidden="true" className="h-4 w-4" />
</span>
);
}
function leafSegment(path: string): string {
const cleaned = path.replace(/\\/g, "/").replace(/\/+$/g, "");
const idx = cleaned.lastIndexOf("/");
return idx >= 0 ? cleaned.slice(idx + 1) : cleaned;
}
+6 -1
View File
@@ -95,7 +95,7 @@ Same as `chromium` but on Playwright's Pixel-5 viewport, gated on `tests/**/mobi
This project:
- **Skips entirely** when no Docker daemon is reachable. Contributors without Docker can still run `chromium` + `mobile-chrome`.
- **Builds container images on demand.** First run builds `kandev-agent:e2e` (slim Node base + git), `kandev-sshd:e2e` (Alpine + openssh-server + git + pre-baked mock-agent), and the Kubernetes runtime/backend image. Subsequent runs hit Docker's layer cache.
- **Builds container images on demand.** First run builds `kandev-agent:e2e` (slim Node base + git), `kandev-sshd:e2e` (Alpine + openssh-server + git + pre-baked mock-agent), and a Kubernetes runtime/backend image derived from its pinned CI runtime base. Subsequent runs hit Docker's layer cache.
- **Has a longer per-test timeout** (180s vs 60s) because container starts + agent setup are slow.
How to run it locally (requires Docker running):
@@ -166,6 +166,11 @@ KANDEV_E2E_CONTAINERS=1 \
pnpm e2e:run --host --project kubernetes-compat tests/kubernetes-compat
```
The fixture application image uses the immutable `KUBERNETES_E2E_BASE_IMAGE`
pin from `kubernetes-pins.ts`. CI pre-pulls this prebuilt GHCR runtime image
with bounded retries. The per-run image build only copies the current backend
and web artifacts, so it does not contact an Ubuntu package mirror.
The compatibility fixture uses the same exact-name ownership marker, narrow
teardown, foreign-image refusal, and credential-redacted diagnostics as the full
suite. Unsupported version selectors fail before provisioning.
+1 -1
View File
@@ -17,7 +17,7 @@ export type KubernetesFixtureVersion = keyof typeof KUBERNETES_FIXTURE_PINS;
export const KUBERNETES_VERSION: KubernetesFixtureVersion = "v1.36.1";
export const KIND_NODE_IMAGE = KUBERNETES_FIXTURE_PINS[KUBERNETES_VERSION].nodeImage;
export const KUBERNETES_E2E_BASE_IMAGE =
"ubuntu:24.04@sha256:33ceb71981b602c1a7443a53469e4dba065f7503eab3078a2d7a57a2ab987517";
"ghcr.io/kdlbs/kandev-ci:runtime-sha-6f288a23c526@sha256:b9636e1c20adb0fcce1c65858a767fb1b0fe48a15ac5688b9c623163575efb8d";
export const KIND_SHA256_AMD64 = "50030de23cf40a18505f20426f6a8506bedf13c6e509244bd1fa9463721b0f54";
export const KUBECTL_SHA256_AMD64 = KUBERNETES_FIXTURE_PINS[KUBERNETES_VERSION].kubectlSha256Amd64;
@@ -211,9 +211,6 @@ function buildRuntimeImage(tag: string): void {
requireBuildArtifacts();
const context = fs.mkdtempSync(path.join(os.tmpdir(), "kandev-kubernetes-e2e-image-"));
const dockerfile = `FROM ${KUBERNETES_E2E_BASE_IMAGE}
RUN apt-get update \\
&& apt-get install -y --no-install-recommends ca-certificates curl git \\
&& rm -rf /var/lib/apt/lists/*
COPY kandev /usr/local/bin/kandev
COPY agentctl-linux-amd64 /usr/local/bin/agentctl-linux-amd64
COPY mock-agent-linux-amd64 /usr/local/bin/mock-agent
+16 -5
View File
@@ -899,19 +899,30 @@ export class ApiClient {
}
/**
* Creates a repository set. `repositoryIds` is ordered and is the order the set
* fills the task-creation picker.
* Creates a repository set. The member list is ordered and is the order the
* set fills the task-creation picker. String ids keep the compatibility
* payload available for older scenarios; member objects exercise saved bases.
*/
async createRepositorySet(
workspaceId: string,
name: string,
repositoryIds: string[],
repositoryIds: string[] | Array<{ repositoryId: string; baseBranch?: string }>,
description = "",
): Promise<{ id: string; name: string }> {
const members = repositoryIds.filter(
(entry): entry is { repositoryId: string; baseBranch?: string } => typeof entry !== "string",
);
return this.request("POST", `/api/v1/workspaces/${workspaceId}/repository-sets`, {
name,
description,
repository_ids: repositoryIds,
...(members.length === repositoryIds.length
? {
repositories: members.map((member) => ({
repository_id: member.repositoryId,
base_branch: member.baseBranch ?? "",
})),
}
: { repository_ids: repositoryIds }),
});
}
@@ -970,7 +981,7 @@ export class ApiClient {
id: string;
name: string;
description: string;
repositories: Array<{ repository_id: string; position: number }>;
repositories: Array<{ repository_id: string; position: number; base_branch?: string }>;
}>;
total: number;
}> {
@@ -7,6 +7,7 @@ import {
KIND_VERSION,
KUBECTL_SHA256_AMD64,
KUBERNETES_FIXTURE_PINS,
KUBERNETES_E2E_BASE_IMAGE,
KUBERNETES_VERSION,
resolveKubernetesFixturePin,
} from "../fixtures/kubernetes-pins";
@@ -22,6 +23,8 @@ const CURRENT_KUBECTL_SHA256_AMD64 =
"629d3f410e09bf49b64ae7079f7f0bda1191efed311f7d37fdbab0ad5b0ec2b7";
const CURRENT_KIND_NODE_IMAGE =
"kindest/node:v1.36.1@sha256:3489c7674813ba5d8b1a9977baea8a6e553784dab7b84759d1014dbd78f7ebd5";
const KUBERNETES_E2E_RUNTIME_IMAGE =
"ghcr.io/kdlbs/kandev-ci:runtime-sha-6f288a23c526@sha256:b9636e1c20adb0fcce1c65858a767fb1b0fe48a15ac5688b9c623163575efb8d";
describe("Kubernetes E2E version pins", () => {
it("uses the current supported Kubernetes release for the full lifecycle fixture", () => {
@@ -49,6 +52,17 @@ describe("Kubernetes E2E version pins", () => {
expect(() => resolveKubernetesFixturePin("v1.33.0")).toThrow(/unsupported/);
});
it("uses a prebuilt immutable runtime image for Kubernetes fixture containers", () => {
const tools = fs.readFileSync(
path.join(REPOSITORY_ROOT, "apps/web/e2e/fixtures/kubernetes-tools.ts"),
"utf8",
);
expect(KUBERNETES_E2E_BASE_IMAGE).toBe(KUBERNETES_E2E_RUNTIME_IMAGE);
expect(tools).toContain("FROM ${KUBERNETES_E2E_BASE_IMAGE}");
expect(tools).not.toContain("RUN apt-get update");
});
it("keeps CI tool installation aligned with the fixture", () => {
const workflow = fs.readFileSync(
path.join(REPOSITORY_ROOT, ".github/workflows/e2e-tests.yml"),
@@ -61,6 +75,7 @@ describe("Kubernetes E2E version pins", () => {
KUBERNETES_VERSION,
KUBECTL_SHA256_AMD64,
KIND_NODE_IMAGE,
KUBERNETES_E2E_BASE_IMAGE,
]) {
expect(workflow).toContain(pin);
}
@@ -94,6 +109,8 @@ describe("Kubernetes E2E version pins", () => {
expect(workflow).toContain("KANDEV_E2E_KUBERNETES_VERSION");
expect(workflow).toContain("KANDEV_E2E_BUILD_IDENTITY");
expect(workflow).toContain("KANDEV_E2E_EXPECTED_SOURCE_REVISION");
expect(workflow).toContain("Pull pinned Kubernetes fixture base image");
expect(workflow).toMatch(/for attempt in 1 2 3; do[\s\S]*docker pull/);
expect(workflow).not.toContain("KANDEV_E2E_SKIP_FRESHNESS");
for (const pin of [
OLDEST_KUBERNETES_VERSION,
@@ -1,7 +1,162 @@
import { execSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import { test, expect } from "../../fixtures/test-base";
import { assertNoDocumentHorizontalOverflow } from "../../helpers/layout-assertions";
import { makeGitEnv } from "../../helpers/git-helper";
test.describe("Mobile workspace repository sets", () => {
test("scrolls a long branch list by touch without dismissing the editor", async ({
testPage,
apiClient,
seedData,
backend,
prCapture,
}) => {
const dir = path.join(backend.tmpDir, "repos", "mobile-set-scroll");
fs.mkdirSync(dir, { recursive: true });
const gitEnv = makeGitEnv(backend.tmpDir);
execSync('git init -b main && git commit --allow-empty -m "init"', { cwd: dir, env: gitEnv });
for (let index = 0; index < 40; index++) {
execSync(`git branch scroll-test-${index}`, { cwd: dir, env: gitEnv });
}
const repository = await apiClient.createRepository(seedData.workspaceId, dir, "main", {
name: "Mobile branch scrolling",
});
const set = await apiClient.createRepositorySet(seedData.workspaceId, "Touch scroll", [
repository.id,
]);
try {
await testPage.goto(`/settings/workspaces/${seedData.workspaceId}/repositories`);
await testPage.getByTestId(`repository-set-edit-${set.id}`).tap();
await testPage.getByTestId(`repository-set-base-${repository.id}`).tap();
const dropdown = testPage.getByTestId(`repository-set-base-dropdown-${repository.id}`);
const list = dropdown.getByRole("listbox");
await expect(list.getByRole("option", { name: /scroll-test-39/ })).toBeAttached();
const box = await list.boundingBox();
expect(box).not.toBeNull();
const cdp = await testPage.context().newCDPSession(testPage);
try {
const x = box!.x + box!.width / 2;
const y = box!.y + box!.height - 20;
await cdp.send("Input.dispatchTouchEvent", { type: "touchStart", touchPoints: [{ x, y }] });
for (let step = 1; step <= 6; step++) {
await cdp.send("Input.dispatchTouchEvent", {
type: "touchMove",
touchPoints: [{ x, y: y - step * 30 }],
});
}
await cdp.send("Input.dispatchTouchEvent", { type: "touchEnd", touchPoints: [] });
await expect.poll(() => list.evaluate((element) => element.scrollTop)).toBeGreaterThan(0);
} finally {
await cdp.detach();
}
await expect(testPage.getByTestId("repository-set-editor-surface")).toBeVisible();
await expect(dropdown).toBeVisible();
await assertNoDocumentHorizontalOverflow(testPage, "mobile branch scroll");
await prCapture.screenshot("mobile-repository-set-branch-scroll", {
caption:
"Touch scrolling moves the branch list without dismissing the repository set editor.",
});
await dropdown.getByPlaceholder("Search branches...").fill("scroll-test-39");
await dropdown.getByRole("option", { name: /scroll-test-39/ }).tap();
await expect(testPage.getByTestId(`repository-set-base-${repository.id}`)).toContainText(
"scroll-test-39",
);
} finally {
await apiClient.deleteRepositorySet(set.id);
await apiClient.rawRequest("DELETE", `/api/v1/repositories/${repository.id}`);
}
});
test("opens the inline editor as a contained full-height drawer", async ({
testPage,
apiClient,
seedData,
backend,
prCapture,
}) => {
await testPage.setViewportSize({ width: 390, height: 844 });
const setName = `Mobile editor set ${Date.now()}`;
const created = await apiClient.createRepositorySet(seedData.workspaceId, setName, [
seedData.repositoryId,
]);
await testPage.goto(`/settings/workspaces/${seedData.workspaceId}/repositories`);
await testPage.getByTestId(`repository-set-edit-${created.id}`).tap();
const surface = testPage.getByTestId("repository-set-editor-surface");
await expect(surface).toBeVisible();
await expect(surface).toHaveClass(/h-\[100dvh\]/);
await expect.poll(async () => (await surface.boundingBox())?.height).toBe(844);
await expect(testPage.getByTestId("repository-set-editor-form")).toHaveClass(
/min-h-0.*overflow-y-auto/,
);
const membersHint = testPage.getByText(
"Add repositories in task order. Base branches are optional.",
);
const addRepository = testPage.getByTestId("repository-set-add-repository");
const [membersHintBox, addRepositoryBox] = await Promise.all([
membersHint.boundingBox(),
addRepository.boundingBox(),
]);
expect(membersHintBox).not.toBeNull();
expect(addRepositoryBox).not.toBeNull();
expect(addRepositoryBox!.y).toBeGreaterThan(membersHintBox!.y + membersHintBox!.height);
expect(addRepositoryBox!.width).toBeCloseTo(membersHintBox!.width, 0);
await testPage.getByTestId(`repository-set-remove-${seedData.repositoryId}`).tap();
await addRepository.tap();
await testPage.getByRole("option", { name: /E2E Repo/ }).tap();
await expect(
testPage.getByTestId(`repository-set-base-${seedData.repositoryId}`),
).toBeVisible();
await prCapture.screenshot("mobile-repository-set-editor", {
caption:
"The mobile repository set editor uses a full-height drawer with a fixed action bar.",
});
for (const control of [
testPage.getByTestId("repository-set-editor-save"),
testPage.getByTestId("repository-set-editor-cancel"),
testPage.getByTestId(`repository-set-base-${seedData.repositoryId}`),
addRepository,
testPage.getByTestId("repository-set-reset-bases"),
]) {
const box = await control.boundingBox();
expect(box).not.toBeNull();
expect(box!.height).toBeGreaterThanOrEqual(44);
}
execSync("git update-ref refs/remotes/origin/main HEAD", {
cwd: seedData.repositoryPath,
env: makeGitEnv(backend.tmpDir),
});
const basePicker = testPage.getByTestId(`repository-set-base-${seedData.repositoryId}`);
await basePicker.tap();
const dropdown = testPage.getByTestId(`repository-set-base-dropdown-${seedData.repositoryId}`);
await expect(dropdown).toBeVisible();
await expect(dropdown.getByPlaceholder("Search branches...")).toBeVisible();
await expect(dropdown.getByText("origin/main")).toBeVisible();
await expect(dropdown.getByText("origin", { exact: true })).toBeVisible();
const search = dropdown.getByPlaceholder("Search branches...");
await search.fill("origin");
await expect(dropdown.getByRole("option", { name: /^origin\/main origin/ })).toBeVisible();
await expect(dropdown.getByRole("option", { name: /^main local/ })).toHaveCount(0);
await dropdown.getByTestId("branch-refresh-button").tap();
await expect(dropdown.getByRole("option", { name: /^origin\/main origin/ })).toBeVisible();
const dropdownBox = await dropdown.boundingBox();
const viewport = testPage.viewportSize();
expect(dropdownBox).not.toBeNull();
expect(viewport).not.toBeNull();
expect(dropdownBox!.x).toBeGreaterThanOrEqual(0);
expect(dropdownBox!.y).toBeGreaterThanOrEqual(0);
expect(dropdownBox!.x + dropdownBox!.width).toBeLessThanOrEqual(viewport!.width);
expect(dropdownBox!.y + dropdownBox!.height).toBeLessThanOrEqual(viewport!.height);
await assertNoDocumentHorizontalOverflow(testPage, "mobile repository-set editor");
});
test("confirms deletion inline without a second overlay", async ({
testPage,
apiClient,
@@ -3,6 +3,7 @@ import fs from "node:fs";
import path from "node:path";
import { test, expect } from "../../fixtures/test-base";
import { makeGitEnv } from "../../helpers/git-helper";
import { expectControlHeight } from "../../helpers/control-sizing";
const SET_ROW = "repository-set-row";
const EDITOR_NAME = "repository-set-editor-name";
@@ -15,12 +16,22 @@ test.describe("Workspace repository sets settings", () => {
apiClient,
seedData,
backend,
prCapture,
}) => {
const dir = path.join(backend.tmpDir, "repos", "settings-repository-sets");
const remoteDir = path.join(backend.tmpDir, "repos", "settings-repository-sets-origin.git");
fs.mkdirSync(dir, { recursive: true });
const gitEnv = makeGitEnv(backend.tmpDir);
execSync(`git init --bare -b main "${remoteDir}"`, { env: gitEnv });
execSync("git init -b main", { cwd: dir, env: gitEnv });
execSync('git commit --allow-empty -m "init"', { cwd: dir, env: gitEnv });
execSync("git branch develop", { cwd: dir, env: gitEnv });
for (let index = 0; index < 40; index++) {
execSync(`git branch scroll-test-${index}`, { cwd: dir, env: gitEnv });
}
execSync(`git remote add origin "file://${remoteDir}"`, { cwd: dir, env: gitEnv });
execSync("git push origin main", { cwd: dir, env: gitEnv });
execSync("git update-ref refs/remotes/origin/main HEAD", { cwd: dir, env: gitEnv });
const second = await apiClient.createRepository(seedData.workspaceId, dir, "main", {
name: SECOND_REPO_NAME,
});
@@ -31,9 +42,78 @@ test.describe("Workspace repository sets settings", () => {
// Create a set holding both repositories.
const setName = `Settings set ${Date.now()}`;
await testPage.getByTestId("repository-set-create").click();
const membersHint = testPage.getByText(
"Add repositories in task order. Base branches are optional.",
);
const addRepository = testPage.getByTestId("repository-set-add-repository");
const [membersHintBox, addRepositoryBox] = await Promise.all([
membersHint.boundingBox(),
addRepository.boundingBox(),
]);
expect(membersHintBox).not.toBeNull();
expect(addRepositoryBox).not.toBeNull();
expect(membersHintBox!.width).toBeGreaterThan(addRepositoryBox!.width);
await testPage.getByTestId(EDITOR_NAME).fill(setName);
await testPage.getByTestId(`repository-set-member-${seedData.repositoryId}`).click();
await testPage.getByTestId(`repository-set-member-${second.id}`).click();
await addRepository.click();
const repositoryOption = testPage.getByRole("option", { name: /E2E Repo/ });
await expect(repositoryOption).toBeVisible();
await expect
.poll(() =>
repositoryOption.evaluate((element) => {
const box = element.getBoundingClientRect();
return [box.x + 8, box.right - 8].every((x) =>
element.contains(document.elementFromPoint(x, box.y + box.height / 2)),
);
}),
)
.toBe(true);
await prCapture.screenshot("desktop-repository-set-add-picker", {
caption: "The repository picker remains fully clickable outside the scrolling form.",
});
await testPage.getByRole("option", { name: /E2E Repo/ }).click();
await testPage.getByTestId("repository-set-add-repository").click();
await testPage.getByRole("option", { name: SECOND_REPO_NAME }).click();
const basePicker = testPage.getByTestId(`repository-set-base-${second.id}`);
for (const control of [
addRepository,
basePicker,
testPage.getByTestId("repository-set-reset-bases"),
testPage.getByTestId(`repository-set-remove-${second.id}`),
])
await expectControlHeight(control, 28);
await basePicker.click();
const dropdown = testPage.getByTestId(`repository-set-base-dropdown-${second.id}`);
await expect(dropdown).toBeVisible();
await expect(dropdown.getByPlaceholder("Search branches...")).toBeVisible();
await expect(dropdown.getByText("Branches")).toBeVisible();
await expect(dropdown.getByRole("option", { name: /^main local/ })).toBeVisible();
await expect(dropdown.getByRole("option", { name: /^origin\/main origin/ })).toBeVisible();
await expect(dropdown.getByText("origin", { exact: true })).toBeVisible();
const branchList = dropdown.getByRole("listbox");
await branchList.hover();
await testPage.mouse.wheel(0, 500);
await expect.poll(() => branchList.evaluate((element) => element.scrollTop)).toBeGreaterThan(0);
await prCapture.screenshot("desktop-repository-set-branch-scroll", {
caption: "Mouse-wheel scrolling reaches branches beyond the initial list inside the dialog.",
});
await testPage.mouse.wheel(0, -500);
await expect.poll(() => branchList.evaluate((element) => element.scrollTop)).toBe(0);
const search = dropdown.getByPlaceholder("Search branches...");
await search.fill("origin");
await expect(dropdown.getByRole("option", { name: /^origin\/main origin/ })).toBeVisible();
await expect(dropdown.getByRole("option", { name: /^main local/ })).toHaveCount(0);
await dropdown.getByTestId("branch-refresh-button").click();
await expect(dropdown.getByRole("option", { name: /^origin\/main origin/ })).toBeVisible();
await search.fill("");
const develop = dropdown.getByRole("option", { name: /^develop local/ });
await expect(develop).toBeVisible();
await develop.click();
await prCapture.screenshot("desktop-repository-set-editor", {
caption: "The desktop repository set editor assigns a saved base branch per member.",
});
await testPage.getByTestId(EDITOR_SAVE).click();
const rows = testPage.getByTestId(SET_ROW);
@@ -43,9 +123,16 @@ test.describe("Workspace repository sets settings", () => {
await expect
.poll(async () => {
const listed = await apiClient.listRepositorySets(seedData.workspaceId);
return listed.repository_sets.find((entry) => entry.name === setName)?.repositories.length;
const stored = listed.repository_sets.find((entry) => entry.name === setName);
return (
stored?.repositories.length === 2 &&
stored.repositories.some((member) => member.repository_id === seedData.repositoryId) &&
stored.repositories.some((member) => member.repository_id === second.id) &&
stored.repositories.find((member) => member.repository_id === second.id)?.base_branch ===
"develop"
);
})
.toBe(2);
.toBe(true);
const createdId = (
await apiClient.listRepositorySets(seedData.workspaceId)
@@ -53,7 +140,7 @@ test.describe("Workspace repository sets settings", () => {
// Edit: drop one member. A supplied membership replaces the whole list.
await testPage.getByTestId(`repository-set-edit-${createdId}`).click();
await testPage.getByTestId(`repository-set-member-${second.id}`).click();
await testPage.getByTestId(`repository-set-remove-${second.id}`).click();
await testPage.getByTestId(EDITOR_SAVE).click();
await expect(rows.first()).toHaveAttribute("data-member-count", "1");
@@ -15,7 +15,9 @@ const REPO_CHIP_TRIGGER = "repo-chip-trigger";
const SECOND_REPO_NAME = "Repository Sets Target";
const SET_NAME = "Full-stack";
type TaskWithRepos = { repositories?: Array<{ repository_id: string }> };
type TaskWithRepos = {
repositories?: Array<{ repository_id: string; base_branch?: string }>;
};
/**
* Seeds a second workspace repository plus a set containing both, and returns
@@ -32,25 +34,33 @@ async function seedSetWithTwoRepositories(
createRepositorySet: (
workspaceId: string,
name: string,
repositoryIds: string[],
repositoryIds: string[] | Array<{ repositoryId: string; baseBranch?: string }>,
) => Promise<{ id: string; name: string }>;
},
seedData: { workspaceId: string; repositoryId: string },
backend: { tmpDir: string },
options: { setName?: string; dirName?: string } = {},
options: { setName?: string; dirName?: string; savedBases?: boolean } = {},
) {
const dir = path.join(backend.tmpDir, "repos", options.dirName ?? "repository-sets-target");
fs.mkdirSync(dir, { recursive: true });
const gitEnv = makeGitEnv(backend.tmpDir);
execSync("git init -b main", { cwd: dir, env: gitEnv });
execSync('git commit --allow-empty -m "init"', { cwd: dir, env: gitEnv });
execSync("git branch develop", { cwd: dir, env: gitEnv });
const second = await apiClient.createRepository(seedData.workspaceId, dir, "main", {
name: SECOND_REPO_NAME,
});
const members: string[] | Array<{ repositoryId: string; baseBranch?: string }> =
options.savedBases
? [
{ repositoryId: seedData.repositoryId, baseBranch: "main" },
{ repositoryId: second.id, baseBranch: "develop" },
]
: [seedData.repositoryId, second.id];
const set = await apiClient.createRepositorySet(
seedData.workspaceId,
options.setName ?? SET_NAME,
[seedData.repositoryId, second.id],
members,
);
return { secondRepositoryId: second.id, setId: set.id };
}
@@ -61,8 +71,11 @@ test.describe("Task creation with repository sets", () => {
apiClient,
seedData,
backend,
prCapture,
}) => {
const { secondRepositoryId } = await seedSetWithTwoRepositories(apiClient, seedData, backend);
const { secondRepositoryId } = await seedSetWithTwoRepositories(apiClient, seedData, backend, {
savedBases: true,
});
await testPage.goto("/");
await testPage.getByTestId("create-task-button").first().click();
@@ -80,6 +93,10 @@ test.describe("Task creation with repository sets", () => {
const chips = dialog.getByTestId(REPO_CHIP_TRIGGER);
await expect(chips).toHaveCount(2);
await expect(chips.nth(1)).toContainText(SECOND_REPO_NAME);
await expect(dialog.getByTestId("repo-chip").nth(1)).toContainText("develop");
await prCapture.screenshot("desktop-repository-set-applied", {
caption: "Applying a repository set on desktop fills the task picker with saved bases.",
});
const title = `Repository set task ${Date.now()}`;
await dialog.getByTestId("task-title-input").fill(title);
@@ -106,6 +123,9 @@ test.describe("Task creation with repository sets", () => {
const repoIds = data.repositories?.map((entry) => entry.repository_id) ?? [];
expect(repoIds).toEqual(expect.arrayContaining([seedData.repositoryId, secondRepositoryId]));
expect(repoIds).toHaveLength(2);
expect(
data.repositories?.find((entry) => entry.repository_id === secondRepositoryId)?.base_branch,
).toBe("develop");
});
test("applying the same set twice adds no duplicate rows", async ({
@@ -25,12 +25,13 @@ test.describe("Repository sets in the mobile task-create picker", () => {
const gitEnv = makeGitEnv(backend.tmpDir);
execSync("git init -b main", { cwd: dir, env: gitEnv });
execSync('git commit --allow-empty -m "init"', { cwd: dir, env: gitEnv });
execSync("git branch develop", { cwd: dir, env: gitEnv });
const second = await apiClient.createRepository(seedData.workspaceId, dir, "main", {
name: SECOND_REPO_NAME,
});
await apiClient.createRepositorySet(seedData.workspaceId, SET_NAME, [
seedData.repositoryId,
second.id,
{ repositoryId: seedData.repositoryId, baseBranch: "main" },
{ repositoryId: second.id, baseBranch: "develop" },
]);
const mobile = new MobileKanbanPage(testPage);
@@ -52,6 +53,7 @@ test.describe("Repository sets in the mobile task-create picker", () => {
await expect(repositoryChips).toHaveCount(2);
await expect(repositoryChips.nth(1)).toContainText(SECOND_REPO_NAME);
await expect(dialog.getByTestId("repo-chip").nth(1)).toContainText("develop");
if (prCapture.capturing) {
// Let the bottom sheet finish dismissing so the asset shows the resulting
@@ -62,5 +64,27 @@ test.describe("Repository sets in the mobile task-create picker", () => {
await prCapture.screenshot("mobile-repository-set-applied", {
caption: "Applying a repository set on a phone fills the picker with both members.",
});
const title = `Mobile repository set task ${Date.now()}`;
await dialog.getByTestId("task-title-input").fill(title);
await dialog.getByTestId("task-description-input").fill("Created from a mobile repository set");
await dialog.getByRole("button", { name: "Create only" }).tap();
await expect(dialog).not.toBeVisible();
let created: { id: string; title: string } | undefined;
await expect
.poll(async () => {
const listed = await apiClient.listTasks(seedData.workspaceId);
created = listed.tasks.find((entry) => entry.title === title);
return created;
})
.toBeDefined();
const raw = await apiClient.rawRequest("GET", `/api/v1/tasks/${created!.id}`);
const data = (await raw.json()) as {
repositories?: Array<{ repository_id: string; base_branch?: string }>;
};
expect(data.repositories?.find((entry) => entry.repository_id === second.id)?.base_branch).toBe(
"develop",
);
});
});
@@ -51,6 +51,8 @@ async function listBranchesUntilSettled(source: BranchSource): Promise<Branch[]>
*/
export type UseBranchesResult = {
branches: Branch[];
/** True after a non-refresh request has completed, including an empty list. */
isLoaded: boolean;
isLoading: boolean;
/**
* Refreshes the branch list. For id-based sources the backend runs
@@ -116,6 +118,7 @@ export function useBranches(source: BranchSource | null, enabled = true): UseBra
return {
branches,
isLoaded,
isLoading,
refresh: source ? refresh : undefined,
};
@@ -76,6 +76,25 @@ describe("repository set API client", () => {
});
});
it("posts ordered repository members with their saved bases", async () => {
await createRepositorySet("ws-1", {
name: "Release",
repositories: [
{ repositoryId: REPO_GATEWAY, baseBranch: "develop" },
{ repositoryId: REPO_WEB },
],
});
expect(bodyOf(calls[0])).toEqual({
name: "Release",
description: "",
repositories: [
{ repository_id: REPO_GATEWAY, base_branch: "develop" },
{ repository_id: REPO_WEB, base_branch: "" },
],
});
});
it("patches the flat item route and omits fields the caller left out", async () => {
await updateRepositorySet(SET_ID, { name: "Renamed" });
@@ -98,6 +117,16 @@ describe("repository set API client", () => {
expect(bodyOf(calls[0])).toEqual({ description: "" });
});
it("patches ordered repository members with their saved bases", async () => {
await updateRepositorySet(SET_ID, {
repositories: [{ repositoryId: REPO_WEB, baseBranch: "develop" }],
});
expect(bodyOf(calls[0])).toEqual({
repositories: [{ repository_id: REPO_WEB, base_branch: "develop" }],
});
});
it("deletes the flat item route", async () => {
await deleteRepositorySet(SET_ID);
+44 -7
View File
@@ -48,6 +48,21 @@ export async function listRepositories(
// Collection routes are workspace-scoped and item routes are flat, mirroring the
// repository routes above.
type RepositorySetMembersPayload =
| {
repositoryIds: string[];
repositories?: never;
}
| {
repositories: Array<{ repositoryId: string; baseBranch?: string }>;
repositoryIds?: never;
};
type CreateRepositorySetPayload = {
name: string;
description?: string;
} & RepositorySetMembersPayload;
export async function listRepositorySets(workspaceId: string, options?: ApiRequestOptions) {
return fetchJson<ListRepositorySetsResponse>(
`/api/v1/workspaces/${encodeURIComponent(workspaceId)}/repository-sets`,
@@ -57,7 +72,7 @@ export async function listRepositorySets(workspaceId: string, options?: ApiReque
export async function createRepositorySet(
workspaceId: string,
payload: { name: string; description?: string; repositoryIds: string[] },
payload: CreateRepositorySetPayload,
options?: ApiRequestOptions,
) {
return fetchJson<RepositorySet>(
@@ -66,11 +81,7 @@ export async function createRepositorySet(
...options,
init: {
method: "POST",
body: JSON.stringify({
name: payload.name,
description: payload.description ?? "",
repository_ids: payload.repositoryIds,
}),
body: JSON.stringify(repositorySetRequestBody(payload)),
...(options?.init ?? {}),
},
},
@@ -84,19 +95,45 @@ export async function createRepositorySet(
*/
export async function updateRepositorySet(
setId: string,
payload: { name?: string; description?: string; repositoryIds?: string[] },
payload: {
name?: string;
description?: string;
repositoryIds?: string[];
repositories?: Array<{ repositoryId: string; baseBranch?: string }>;
},
options?: ApiRequestOptions,
) {
const body: Record<string, unknown> = {};
if (payload.name !== undefined) body.name = payload.name;
if (payload.description !== undefined) body.description = payload.description;
if (payload.repositoryIds !== undefined) body.repository_ids = payload.repositoryIds;
if (payload.repositories !== undefined) {
body.repositories = payload.repositories.map((member) => ({
repository_id: member.repositoryId,
base_branch: member.baseBranch ?? "",
}));
}
return fetchJson<RepositorySet>(`/api/v1/repository-sets/${encodeURIComponent(setId)}`, {
...options,
init: { method: "PATCH", body: JSON.stringify(body), ...(options?.init ?? {}) },
});
}
function repositorySetRequestBody(payload: CreateRepositorySetPayload) {
return {
name: payload.name,
description: payload.description ?? "",
...(payload.repositories
? {
repositories: payload.repositories.map((member) => ({
repository_id: member.repositoryId,
base_branch: member.baseBranch ?? "",
})),
}
: { repository_ids: payload.repositoryIds ?? [] }),
};
}
export async function deleteRepositorySet(setId: string, options?: ApiRequestOptions) {
return fetchJson<void>(`/api/v1/repository-sets/${encodeURIComponent(setId)}`, {
...options,
+1 -1
View File
@@ -252,7 +252,7 @@ export type RepositorySetPayload = {
workspace_id: string;
name?: string;
description?: string;
repositories?: Array<{ repository_id: string; position: number }>;
repositories?: Array<{ repository_id: string; position: number; base_branch?: string }>;
created_at?: string;
updated_at?: string;
};
+4 -2
View File
@@ -348,8 +348,8 @@ export type RepositorySecretBinding = {
* A named, reusable group of workspace repositories. Applying one fills the
* task-creation repository picker in a single action.
*
* A set deliberately carries no branch: branch choice belongs to the task, and
* the picker's existing per-row defaulting fills it after a set is applied.
* A set stores an optional base branch for each member. Applying a set copies
* that value into the task draft; it never creates a live link to the set.
*/
export type RepositorySet = {
id: string;
@@ -365,6 +365,8 @@ export type RepositorySet = {
export type RepositorySetItem = {
repository_id: RepositoryId;
position: number;
/** Empty or absent means that the task form should use its normal default. */
base_branch?: string;
};
export type RepositoryScript = {
@@ -21,7 +21,7 @@ function payload(overrides: Record<string, unknown> = {}) {
description: "web + gateway",
repositories: [
{ repository_id: "repo-web", position: 0 },
{ repository_id: "repo-gateway", position: 1 },
{ repository_id: "repo-gateway", position: 1, base_branch: "develop" },
],
created_at: "2026-08-17T09:00:00Z",
updated_at: "2026-08-17T09:00:00Z",
@@ -63,6 +63,7 @@ describe("repository_set WebSocket handlers", () => {
"repo-web",
"repo-gateway",
]);
expect(sets[0].repositories[1].base_branch).toBe("develop");
});
it("updated replaces the existing set rather than adding a second copy", () => {
@@ -60,6 +60,7 @@ function toRepositorySetItems(items: RepositorySetPayload["repositories"]): Repo
.map((entry, index) => ({
repository_id: repositoryId(entry.repository_id),
position: typeof entry.position === "number" ? entry.position : index,
base_branch: entry.base_branch ?? "",
}))
.sort((left, right) => left.position - right.position);
}
+7 -1
View File
@@ -1,4 +1,6 @@
{
"repositorySetsSaveDuplicateRows_one": "{{count}} additional row is not included. Only the first row for each repository is saved, including its base choice.",
"repositorySetsSaveDuplicateRows_other": "{{count}} additional rows are not included. Only the first row for each repository is saved, including its base choice.",
"agentCliNotInstalled": "Agent CLI not installed",
"agentProbeFailed": "Agent probe failed",
"aPullRequestAlreadyExistsFor": "A pull request already exists for this task",
@@ -1271,7 +1273,7 @@
"repositoryWithPath": "Repository · {{path}}",
"repositorySetsTrigger": "Sets",
"repositorySetsSaveTitle": "Save as repository set",
"repositorySetsSaveDescription": "Reuse this repository selection on future tasks. Branches are chosen per task and are not saved.",
"repositorySetsSaveDescription": "Reuse this repository selection and its saved base branches on future tasks. Checkout branches stay task-specific.",
"repositorySetsSaveNameLabel": "Name",
"repositorySetsSaveDescriptionLabel": "Description (optional)",
"repositorySetsSaveSubmit": "Save set",
@@ -1295,6 +1297,10 @@
"repositorySetsNothingToApply": "Every repository in {{name}} is already in this task",
"repositorySetsSkippedMissing_one": "{{count}} repository in {{name}} no longer exists in this workspace",
"repositorySetsSkippedMissing_other": "{{count}} repositories in {{name}} no longer exist in this workspace",
"repositorySetBaseUnavailable": "A saved base branch is unavailable. Choose another branch before creating the task.",
"repositorySetBaseForLocal": "Base: {{base}}",
"branchUnavailable": "This saved branch is not available. Choose another branch.",
"branchUnavailableShort": "Unavailable",
"requestChanges": "Request changes",
"requestedChanges": "requested changes",
"requestedChangesPlain": "{{who}} requested changes",
+19 -3
View File
@@ -136,11 +136,11 @@
"sourceFolderAlreadySelected": "This folder is already selected.",
"sourceRepoBranchAlreadySelected": "This repository and branch are already selected.",
"repositorySetsTitle": "Repository sets",
"repositorySetsDescription": "Named groups of this workspace's repositories. Choosing a set in the task dialog fills the repository picker with its members; branches are still chosen per task.",
"repositorySetsDescription": "Named groups of this workspace's repositories. Choosing a set in the task dialog fills the repository picker and copies its saved base branches into the task; checkout branches remain task-specific.",
"repositorySetsCreate": "New set",
"repositorySetsCreateTitle": "New repository set",
"repositorySetsEditTitle": "Edit repository set",
"repositorySetsEditorDescription": "Pick the repositories in this set and the order they fill the picker.",
"repositorySetsEditorDescription": "Pick the repositories in this set, their order, and an optional base branch for each.",
"repositorySetsEdit": "Edit set",
"repositorySetsDelete": "Delete set",
"repositorySetsDeleteTitle": "Delete {{name}}?",
@@ -151,15 +151,31 @@
"repositorySetsNameLabel": "Name",
"repositorySetsDescriptionLabel": "Description (optional)",
"repositorySetsMembersLabel": "Repositories",
"repositorySetsMembersHint": "Select at least one. The order here is the order they are added to a task.",
"repositorySetsMembersHint": "Add repositories in task order. Base branches are optional.",
"repositorySetsMoveUp": "Move earlier",
"repositorySetsMoveDown": "Move later",
"repositorySetsRemove": "Remove repository",
"repositorySetsSave": "Save set",
"repositorySetsNameTaken": "A set with this name already exists in this workspace.",
"repositorySetsMemberRejected": "One of the chosen repositories is no longer available in this workspace.",
"repositorySetsSaveFailed": "Could not save the set. Try again.",
"repositorySetsAlreadyDeleted": "This set was already deleted. Refresh to see the current list.",
"repositorySetsDeleteFailed": "Could not delete the set. Try again.",
"repositorySetsAddRepository": "Add repository",
"repositorySetsSearchRepositories": "Search repositories...",
"repositorySetsNoMatchingRepositories": "No matching repositories.",
"repositorySetsSearchSelected": "Filter selected repositories...",
"repositorySetsNoMatchingMembers": "No selected repositories match this search.",
"repositorySetsNoMembersSelected": "No repositories selected yet.",
"repositorySetsResetBases": "Reset bases",
"repositorySetsTaskDefault": "Task default ({{branch}})",
"repositorySetsTaskDefaultNoBranch": "Task default",
"repositorySetsBaseBranchLabel": "Saved base branch",
"repositorySetsBaseBranchFor": "Saved base branch for {{name}}",
"repositorySetsBranchUnavailable": "Unavailable",
"repositorySetsBranchUnavailableReason": "This saved branch is not available. Choose another branch or reset to the task default.",
"repositorySetsMemberMissing": "Repository no longer exists in this workspace.",
"repositorySetsMemberBase": "base: {{base}}",
"branchPoliciesTitle": "Branch policies",
"branchPoliciesDescription": "Choose a named branch policy when you create a task. The selected policy is saved with the task.",
"branchPoliciesSaveRepositoryFirst": "Save this repository before adding branch policies.",
+7 -1
View File
@@ -1,4 +1,6 @@
{
"repositorySetsSaveDuplicateRows_one": "{{count}} àďďĩţĩōńàĺ ŕōŵ ĩś ńōţ ĩńćĺũďēď. Ōńĺŷ ţĥē ƒĩŕśţ ŕōŵ ƒōŕ ēàćĥ ŕēƥōśĩţōŕŷ ĩś śàvēď, ĩńćĺũďĩńĝ ĩţś ƀàśē ćĥōĩćē.",
"repositorySetsSaveDuplicateRows_other": "{{count}} àďďĩţĩōńàĺ ŕōŵś àŕē ńōţ ĩńćĺũďēď. Ōńĺŷ ţĥē ƒĩŕśţ ŕōŵ ƒōŕ ēàćĥ ŕēƥōśĩţōŕŷ ĩś śàvēď, ĩńćĺũďĩńĝ ĩţś ƀàśē ćĥōĩćē.",
"agentCliNotInstalled": "Àĝēńţ ĆĹĨ ńōţ ĩńśţàĺĺēď",
"agentProbeFailed": "Àĝēńţ ƥŕōƀē ƒàĩĺēď",
"aPullRequestAlreadyExistsFor": "À ƥũĺĺ ŕēqũēśţ àĺŕēàďŷ ēxĩśţś ƒōŕ ţĥĩś ţàśķ",
@@ -1271,7 +1273,7 @@
"repositoryWithPath": "Ŕēƥōśĩţōŕŷ · {{path}}",
"repositorySetsTrigger": "Śēţś",
"repositorySetsSaveTitle": "Śàvē àś ŕēƥōśĩţōŕŷ śēţ",
"repositorySetsSaveDescription": "Ŕēũśē ţĥĩś ŕēƥōśĩţōŕŷ śēĺēćţĩōń ōń ƒũţũŕē ţàśķś. Ɓŕàńćĥēś àŕē ćĥōśēń ƥēŕ ţàśķ àńď àŕē ńōţ śàvēď.",
"repositorySetsSaveDescription": "Ŕēũśē ţĥĩś ŕēƥōśĩţōŕŷ śēĺēćţĩōń àńď ĩţś śàvēď ƀàśē ƀŕàńćĥēś ōń ƒũţũŕē ţàśķś. Ćĥēćķōũţ ƀŕàńćĥēś śţàŷ ţàśķ-śƥēćĩƒĩć.",
"repositorySetsSaveNameLabel": "Ńàḿē",
"repositorySetsSaveDescriptionLabel": "Ďēśćŕĩƥţĩōń (ōƥţĩōńàĺ)",
"repositorySetsSaveSubmit": "Śàvē śēţ",
@@ -1295,6 +1297,10 @@
"repositorySetsNothingToApply": "Ēvēŕŷ ŕēƥōśĩţōŕŷ ĩń {{name}} ĩś àĺŕēàďŷ ĩń ţĥĩś ţàśķ",
"repositorySetsSkippedMissing_one": "{{count}} ŕēƥōśĩţōŕŷ ĩń {{name}} ńō ĺōńĝēŕ ēxĩśţś ĩń ţĥĩś ŵōŕķśƥàćē",
"repositorySetsSkippedMissing_other": "{{count}} ŕēƥōśĩţōŕĩēś ĩń {{name}} ńō ĺōńĝēŕ ēxĩśţ ĩń ţĥĩś ŵōŕķśƥàćē",
"repositorySetBaseUnavailable": "À śàvēď ƀàśē ƀŕàńćĥ ĩś ũńàvàĩĺàƀĺē. Ćĥōōśē àńōţĥēŕ ƀŕàńćĥ ƀēƒōŕē ćŕēàţĩńĝ ţĥē ţàśķ.",
"repositorySetBaseForLocal": "Ɓàśē: {{base}}",
"branchUnavailable": "Ţĥĩś śàvēď ƀŕàńćĥ ĩś ńōţ àvàĩĺàƀĺē. Ćĥōōśē àńōţĥēŕ ƀŕàńćĥ.",
"branchUnavailableShort": "Ũńàvàĩĺàƀĺē",
"requestChanges": "Ŕēqũēśţ ćĥàńĝēś",
"requestedChanges": "ŕēqũēśţēď ćĥàńĝēś",
"requestedChangesPlain": "{{who}} ŕēqũēśţēď ćĥàńĝēś",
+19 -3
View File
@@ -136,11 +136,11 @@
"sourceFolderAlreadySelected": "Ţĥĩś ƒōĺďēŕ ĩś àĺŕēàďŷ śēĺēćţēď.",
"sourceRepoBranchAlreadySelected": "Ţĥĩś ŕēƥōśĩţōŕŷ àńď ƀŕàńćĥ àŕē àĺŕēàďŷ śēĺēćţēď.",
"repositorySetsTitle": "Ŕēƥōśĩţōŕŷ śēţś",
"repositorySetsDescription": "Ńàḿēď ĝŕōũƥś ōƒ ţĥĩś ŵōŕķśƥàćē'ś ŕēƥōśĩţōŕĩēś. Ćĥōōśĩńĝ à śēţ ĩń ţĥē ţàśķ ďĩàĺōĝ ƒĩĺĺś ţĥē ŕēƥōśĩţōŕŷ ƥĩćķēŕ ŵĩţĥ ĩţś ḿēḿƀēŕś; ƀŕàńćĥēś àŕē śţĩĺĺ ćĥōśēń ƥēŕ ţàśķ.",
"repositorySetsDescription": "Ńàḿēď ĝŕōũƥś ōƒ ţĥĩś ŵōŕķśƥàćē'ś ŕēƥōśĩţōŕĩēś. Ćĥōōśĩńĝ à śēţ ĩń ţĥē ţàśķ ďĩàĺōĝ ƒĩĺĺś ţĥē ŕēƥōśĩţōŕŷ ƥĩćķēŕ àńď ćōƥĩēś ĩţś śàvēď ƀàśē ƀŕàńćĥēś ĩńţō ţĥē ţàśķ; ćĥēćķōũţ ƀŕàńćĥēś ŕēḿàĩń ţàśķ-śƥēćĩƒĩć.",
"repositorySetsCreate": "Ńēŵ śēţ",
"repositorySetsCreateTitle": "Ńēŵ ŕēƥōśĩţōŕŷ śēţ",
"repositorySetsEditTitle": "Ēďĩţ ŕēƥōśĩţōŕŷ śēţ",
"repositorySetsEditorDescription": "Ƥĩćķ ţĥē ŕēƥōśĩţōŕĩēś ĩń ţĥĩś śēţ àńď ţĥē ōŕďēŕ ţĥēŷ ƒĩĺĺ ţĥē ƥĩćķēŕ.",
"repositorySetsEditorDescription": "Ƥĩćķ ţĥē ŕēƥōśĩţōŕĩēś ĩń ţĥĩś śēţ, ţĥēĩŕ ōŕďēŕ, àńď àń ōƥţĩōńàĺ ƀàśē ƀŕàńćĥ ƒōŕ ēàćĥ.",
"repositorySetsEdit": "Ēďĩţ śēţ",
"repositorySetsDelete": "Ďēĺēţē śēţ",
"repositorySetsDeleteTitle": "Ďēĺēţē {{name}}?",
@@ -151,15 +151,31 @@
"repositorySetsNameLabel": "Ńàḿē",
"repositorySetsDescriptionLabel": "Ďēśćŕĩƥţĩōń (ōƥţĩōńàĺ)",
"repositorySetsMembersLabel": "Ŕēƥōśĩţōŕĩēś",
"repositorySetsMembersHint": "Śēĺēćţ àţ ĺēàśţ ōńē. Ţĥē ōŕďēŕ ĥēŕē ĩś ţĥē ōŕďēŕ ţĥēŷ àŕē àďďēď ţō à ţàśķ.",
"repositorySetsMembersHint": "Àďď ŕēƥōśĩţōŕĩēś ĩń ţàśķ ōŕďēŕ. Ɓàśē ƀŕàńćĥēś àŕē ōƥţĩōńàĺ.",
"repositorySetsMoveUp": "Ḿōvē ēàŕĺĩēŕ",
"repositorySetsMoveDown": "Ḿōvē ĺàţēŕ",
"repositorySetsRemove": "Ŕēḿōvē ŕēƥōśĩţōŕŷ",
"repositorySetsSave": "Śàvē śēţ",
"repositorySetsNameTaken": "À śēţ ŵĩţĥ ţĥĩś ńàḿē àĺŕēàďŷ ēxĩśţś ĩń ţĥĩś ŵōŕķśƥàćē.",
"repositorySetsMemberRejected": "Ōńē ōƒ ţĥē ćĥōśēń ŕēƥōśĩţōŕĩēś ĩś ńō ĺōńĝēŕ àvàĩĺàƀĺē ĩń ţĥĩś ŵōŕķśƥàćē.",
"repositorySetsSaveFailed": "Ćōũĺď ńōţ śàvē ţĥē śēţ. Ţŕŷ àĝàĩń.",
"repositorySetsAlreadyDeleted": "Ţĥĩś śēţ ŵàś àĺŕēàďŷ ďēĺēţēď. Ŕēƒŕēśĥ ţō śēē ţĥē ćũŕŕēńţ ĺĩśţ.",
"repositorySetsDeleteFailed": "Ćōũĺď ńōţ ďēĺēţē ţĥē śēţ. Ţŕŷ àĝàĩń.",
"repositorySetsAddRepository": "Àďď ŕēƥōśĩţōŕŷ",
"repositorySetsSearchRepositories": "Śēàŕćĥ ŕēƥōśĩţōŕĩēś...",
"repositorySetsNoMatchingRepositories": "Ńō ḿàţćĥĩńĝ ŕēƥōśĩţōŕĩēś.",
"repositorySetsSearchSelected": "Ƒĩĺţēŕ śēĺēćţēď ŕēƥōśĩţōŕĩēś...",
"repositorySetsNoMatchingMembers": "Ńō śēĺēćţēď ŕēƥōśĩţōŕĩēś ḿàţćĥ ţĥĩś śēàŕćĥ.",
"repositorySetsNoMembersSelected": "Ńō ŕēƥōśĩţōŕĩēś śēĺēćţēď ŷēţ.",
"repositorySetsResetBases": "Ŕēśēţ ƀàśēś",
"repositorySetsTaskDefault": "Ţàśķ ďēƒàũĺţ ({{branch}})",
"repositorySetsTaskDefaultNoBranch": "Ţàśķ ďēƒàũĺţ",
"repositorySetsBaseBranchLabel": "Śàvēď ƀàśē ƀŕàńćĥ",
"repositorySetsBaseBranchFor": "Śàvēď ƀàśē ƀŕàńćĥ ƒōŕ {{name}}",
"repositorySetsBranchUnavailable": "Ũńàvàĩĺàƀĺē",
"repositorySetsBranchUnavailableReason": "Ţĥĩś śàvēď ƀŕàńćĥ ĩś ńōţ àvàĩĺàƀĺē. Ćĥōōśē àńōţĥēŕ ƀŕàńćĥ ōŕ ŕēśēţ ţō ţĥē ţàśķ ďēƒàũĺţ.",
"repositorySetsMemberMissing": "Ŕēƥōśĩţōŕŷ ńō ĺōńĝēŕ ēxĩśţś ĩń ţĥĩś ŵōŕķśƥàćē.",
"repositorySetsMemberBase": "ƀàśē: {{base}}",
"branchPoliciesTitle": "Ɓŕàńćĥ ƥōĺĩćĩēś",
"branchPoliciesDescription": "Ćĥōōśē à ńàḿēď ƀŕàńćĥ ƥōĺĩćŷ ŵĥēń ŷōũ ćŕēàţē à ţàśķ. Ţĥē śēĺēćţēď ƥōĺĩćŷ ĩś śàvēď ŵĩţĥ ţĥē ţàśķ.",
"branchPoliciesSaveRepositoryFirst": "Śàvē ţĥĩś ŕēƥōśĩţōŕŷ ƀēƒōŕē àďďĩńĝ ƀŕàńćĥ ƥōĺĩćĩēś.",
+7 -1
View File
@@ -1,4 +1,6 @@
{
"repositorySetsSaveDuplicateRows_one": "{{count}} linha adicional não é incluída. Só é guardada a primeira linha de cada repositório, incluindo a escolha da base.",
"repositorySetsSaveDuplicateRows_other": "{{count}} linhas adicionais não são incluídas. Só é guardada a primeira linha de cada repositório, incluindo a escolha da base.",
"agentCliNotInstalled": "CLI do agente não instalado",
"agentProbeFailed": "A sondagem do agente falhou",
"aPullRequestAlreadyExistsFor": "Já existe um pull request para esta tarefa",
@@ -1214,7 +1216,7 @@
"repositoryWithPath": "Repositório · {{path}}",
"repositorySetsTrigger": "Conjuntos",
"repositorySetsSaveTitle": "Guardar como conjunto de repositórios",
"repositorySetsSaveDescription": "Reutilize esta seleção de repositórios em tarefas futuras. Os ramos são escolhidos em cada tarefa e não são guardados.",
"repositorySetsSaveDescription": "Reutilize esta seleção de repositórios e os seus ramos base guardados em tarefas futuras. Os ramos de checkout continuam específicos de cada tarefa.",
"repositorySetsSaveNameLabel": "Nome",
"repositorySetsSaveDescriptionLabel": "Descrição (opcional)",
"repositorySetsSaveSubmit": "Guardar conjunto",
@@ -1238,6 +1240,10 @@
"repositorySetsNothingToApply": "Todos os repositórios de {{name}} já estão nesta tarefa",
"repositorySetsSkippedMissing_one": "{{count}} repositório de {{name}} já não existe nesta área de trabalho",
"repositorySetsSkippedMissing_other": "{{count}} repositórios de {{name}} já não existem nesta área de trabalho",
"repositorySetBaseUnavailable": "Um ramo base guardado não está disponível. Escolha outro ramo antes de criar a tarefa.",
"repositorySetBaseForLocal": "Ramo base: {{base}}",
"branchUnavailable": "Este ramo guardado não está disponível. Escolha outro ramo.",
"branchUnavailableShort": "Indisponível",
"requestChanges": "Solicitar alterações",
"requestedChanges": "solicitou alterações",
"requestedChangesPlain": "{{who}} solicitou alterações",
+19 -3
View File
@@ -136,11 +136,11 @@
"sourceFolderAlreadySelected": "Esta pasta já está selecionada.",
"sourceRepoBranchAlreadySelected": "Este repositório e ramo já estão selecionados.",
"repositorySetsTitle": "Conjuntos de repositórios",
"repositorySetsDescription": "Grupos com nome dos repositórios desta área de trabalho. Escolher um conjunto na caixa de criação de tarefas preenche o seletor de repositórios com os seus membros; os ramos continuam a ser escolhidos em cada tarefa.",
"repositorySetsDescription": "Grupos com nome dos repositórios desta área de trabalho. Escolher um conjunto na caixa de criação de tarefas preenche o seletor de repositórios e copia os seus ramos base guardados para a tarefa; os ramos de checkout continuam específicos de cada tarefa.",
"repositorySetsCreate": "Novo conjunto",
"repositorySetsCreateTitle": "Novo conjunto de repositórios",
"repositorySetsEditTitle": "Editar conjunto de repositórios",
"repositorySetsEditorDescription": "Escolha os repositórios deste conjunto e a ordem em que preenchem o seletor.",
"repositorySetsEditorDescription": "Escolha os repositórios deste conjunto, a sua ordem e um ramo base opcional para cada um.",
"repositorySetsEdit": "Editar conjunto",
"repositorySetsDelete": "Eliminar conjunto",
"repositorySetsDeleteTitle": "Eliminar {{name}}?",
@@ -151,15 +151,31 @@
"repositorySetsNameLabel": "Nome",
"repositorySetsDescriptionLabel": "Descrição (opcional)",
"repositorySetsMembersLabel": "Repositórios",
"repositorySetsMembersHint": "Selecione pelo menos um. Esta ordem é a ordem em que são adicionados a uma tarefa.",
"repositorySetsMembersHint": "Adicione repositórios pela ordem da tarefa. Os ramos base são opcionais.",
"repositorySetsMoveUp": "Mover para cima",
"repositorySetsMoveDown": "Mover para baixo",
"repositorySetsRemove": "Remover repositório",
"repositorySetsSave": "Guardar conjunto",
"repositorySetsNameTaken": "Já existe um conjunto com este nome nesta área de trabalho.",
"repositorySetsMemberRejected": "Um dos repositórios escolhidos já não está disponível nesta área de trabalho.",
"repositorySetsSaveFailed": "Não foi possível guardar o conjunto. Tente novamente.",
"repositorySetsAlreadyDeleted": "Este conjunto já foi eliminado. Atualize para ver a lista atual.",
"repositorySetsDeleteFailed": "Não foi possível eliminar o conjunto. Tente novamente.",
"repositorySetsAddRepository": "Adicionar repositório",
"repositorySetsSearchRepositories": "Pesquisar repositórios...",
"repositorySetsNoMatchingRepositories": "Não foram encontrados repositórios correspondentes.",
"repositorySetsSearchSelected": "Filtrar repositórios selecionados...",
"repositorySetsNoMatchingMembers": "Nenhum repositório selecionado corresponde à pesquisa.",
"repositorySetsNoMembersSelected": "Ainda não existem repositórios selecionados.",
"repositorySetsResetBases": "Repor ramos base",
"repositorySetsTaskDefault": "Predefinição da tarefa ({{branch}})",
"repositorySetsTaskDefaultNoBranch": "Predefinição da tarefa",
"repositorySetsBaseBranchLabel": "Ramo base guardado",
"repositorySetsBaseBranchFor": "Ramo base guardado para {{name}}",
"repositorySetsBranchUnavailable": "Indisponível",
"repositorySetsBranchUnavailableReason": "Este ramo guardado não está disponível. Escolha outro ramo ou reponha a predefinição da tarefa.",
"repositorySetsMemberMissing": "O repositório já não existe nesta área de trabalho.",
"repositorySetsMemberBase": "base: {{base}}",
"branchPoliciesTitle": "Políticas de branches",
"branchPoliciesDescription": "Escolha uma política de branches com nome ao criar uma tarefa. A política selecionada fica guardada na tarefa.",
"branchPoliciesSaveRepositoryFirst": "Guarde este repositório antes de adicionar políticas de branches.",
+7 -1
View File
@@ -1,4 +1,6 @@
{
"repositorySetsSaveDuplicateRows_one": "不包含另外 {{count}} 行。每个仓库只保存第一行及其基准分支选择。",
"repositorySetsSaveDuplicateRows_other": "不包含另外 {{count}} 行。每个仓库只保存第一行及其基准分支选择。",
"agentCliNotInstalled": "未安装智能体 CLI",
"agentProbeFailed": "智能体探测失败",
"aPullRequestAlreadyExistsFor": "此任务已存在拉取请求",
@@ -1213,7 +1215,7 @@
"repositoryWithPath": "仓库 · {{path}}",
"repositorySetsTrigger": "仓库集",
"repositorySetsSaveTitle": "另存为仓库集",
"repositorySetsSaveDescription": "在后续任务中复用此仓库选择。分支按任务单独选择,不会被保存。",
"repositorySetsSaveDescription": "在后续任务中复用此仓库选择及其保存的基础分支。检出分支仍按任务单独设置。",
"repositorySetsSaveNameLabel": "名称",
"repositorySetsSaveDescriptionLabel": "描述(可选)",
"repositorySetsSaveSubmit": "保存",
@@ -1237,6 +1239,10 @@
"repositorySetsNothingToApply": "{{name}} 中的所有仓库都已在此任务中",
"repositorySetsSkippedMissing_one": "{{name}} 中有 {{count}} 个仓库已不存在于此工作区",
"repositorySetsSkippedMissing_other": "{{name}} 中有 {{count}} 个仓库已不存在于此工作区",
"repositorySetBaseUnavailable": "保存的基础分支不可用。请在创建任务前选择其他分支。",
"repositorySetBaseForLocal": "基础分支:{{base}}",
"branchUnavailable": "此保存的分支不可用。请选择其他分支。",
"branchUnavailableShort": "不可用",
"requestChanges": "请求更改",
"requestedChanges": "已请求更改",
"requestedChangesPlain": "{{who}} 请求了更改",
+19 -3
View File
@@ -136,11 +136,11 @@
"sourceFolderAlreadySelected": "该目录已被选择。",
"sourceRepoBranchAlreadySelected": "该仓库和分支已被选择。",
"repositorySetsTitle": "仓库集",
"repositorySetsDescription": "本工作区仓库的命名分组。在任务创建对话框中选择某个仓库集会用其成员填充仓库选择器;分支仍按任务单独选择。",
"repositorySetsDescription": "本工作区仓库的命名分组。在任务创建对话框中选择仓库集会填充仓库选择器,并将保存的基础分支复制到任务中;检出分支仍按任务单独设置。",
"repositorySetsCreate": "新建仓库集",
"repositorySetsCreateTitle": "新建仓库集",
"repositorySetsEditTitle": "编辑仓库集",
"repositorySetsEditorDescription": "选择此仓库集包含的仓库,以及它们填充选择器的顺序。",
"repositorySetsEditorDescription": "选择此仓库集包含的仓库、顺序,以及每个仓库可选的基础分支。",
"repositorySetsEdit": "编辑仓库集",
"repositorySetsDelete": "删除仓库集",
"repositorySetsDeleteTitle": "删除 {{name}}?",
@@ -151,15 +151,31 @@
"repositorySetsNameLabel": "名称",
"repositorySetsDescriptionLabel": "描述(可选)",
"repositorySetsMembersLabel": "仓库",
"repositorySetsMembersHint": "请至少选择一个。此处的顺序即添加到任务时的顺序。",
"repositorySetsMembersHint": "按任务顺序添加仓库。基础分支可选。",
"repositorySetsMoveUp": "上移",
"repositorySetsMoveDown": "下移",
"repositorySetsRemove": "移除仓库",
"repositorySetsSave": "保存",
"repositorySetsNameTaken": "此工作区中已存在同名的仓库集。",
"repositorySetsMemberRejected": "所选仓库之一在此工作区中已不可用。",
"repositorySetsSaveFailed": "无法保存该仓库集。请重试。",
"repositorySetsAlreadyDeleted": "该仓库集已被删除。请刷新以查看当前列表。",
"repositorySetsDeleteFailed": "无法删除该仓库集。请重试。",
"repositorySetsAddRepository": "添加仓库",
"repositorySetsSearchRepositories": "搜索仓库…",
"repositorySetsNoMatchingRepositories": "没有匹配的仓库。",
"repositorySetsSearchSelected": "筛选已选仓库…",
"repositorySetsNoMatchingMembers": "没有选中的仓库匹配此搜索。",
"repositorySetsNoMembersSelected": "尚未选择仓库。",
"repositorySetsResetBases": "重置基础分支",
"repositorySetsTaskDefault": "任务默认值({{branch}})",
"repositorySetsTaskDefaultNoBranch": "任务默认值",
"repositorySetsBaseBranchLabel": "保存的基础分支",
"repositorySetsBaseBranchFor": "{{name}} 的保存基础分支",
"repositorySetsBranchUnavailable": "不可用",
"repositorySetsBranchUnavailableReason": "此保存的分支不可用。请选择其他分支,或重置为任务默认值。",
"repositorySetsMemberMissing": "该仓库已不在此工作区中。",
"repositorySetsMemberBase": "基础分支:{{base}}",
"branchPoliciesTitle": "分支策略",
"branchPoliciesDescription": "创建任务时选择一个命名分支策略。所选策略会保存到任务中。",
"branchPoliciesSaveRepositoryFirst": "请先保存此仓库,再添加分支策略。",
+7 -1
View File
@@ -1,4 +1,6 @@
{
"repositorySetsSaveDuplicateRows_one": "不包含另外 {{count}} 行。每個儲存庫只儲存第一行及其基準分支選擇。",
"repositorySetsSaveDuplicateRows_other": "不包含另外 {{count}} 行。每個儲存庫只儲存第一行及其基準分支選擇。",
"agentCliNotInstalled": "未安裝代理程式 CLI",
"agentProbeFailed": "代理程式探測失敗",
"aPullRequestAlreadyExistsFor": "此任務已存在拉取要求",
@@ -1213,7 +1215,7 @@
"repositoryWithPath": "儲存庫 · {{path}}",
"repositorySetsTrigger": "儲存庫集",
"repositorySetsSaveTitle": "另存為儲存庫集",
"repositorySetsSaveDescription": "在後續任務中複用此儲存庫選擇。分支按任務單獨選擇,不會被儲存。",
"repositorySetsSaveDescription": "在後續任務中複用此儲存庫選擇及其儲存的基礎分支。檢出分支仍按任務單獨設定。",
"repositorySetsSaveNameLabel": "名稱",
"repositorySetsSaveDescriptionLabel": "描述(可選)",
"repositorySetsSaveSubmit": "儲存",
@@ -1237,6 +1239,10 @@
"repositorySetsNothingToApply": "{{name}} 中的所有儲存庫都已在此任務中",
"repositorySetsSkippedMissing_one": "{{name}} 中有 {{count}} 個儲存庫已不存在於此工作區",
"repositorySetsSkippedMissing_other": "{{name}} 中有 {{count}} 個儲存庫已不存在於此工作區",
"repositorySetBaseUnavailable": "儲存的基礎分支不可用。請在建立任務前選擇其他分支。",
"repositorySetBaseForLocal": "基礎分支:{{base}}",
"branchUnavailable": "此儲存的分支不可用。請選擇其他分支。",
"branchUnavailableShort": "不可用",
"requestChanges": "請求更改",
"requestedChanges": "已請求更改",
"requestedChangesPlain": "{{who}} 請求了更改",
+19 -3
View File
@@ -136,11 +136,11 @@
"sourceFolderAlreadySelected": "該目錄已被選擇。",
"sourceRepoBranchAlreadySelected": "該儲存庫和分支已被選擇。",
"repositorySetsTitle": "儲存庫集",
"repositorySetsDescription": "本工作區儲存庫的命名分組。在任務建立對話框中選擇某個儲存庫集會用其成員填充儲存庫選擇器;分支仍按任務單獨選擇。",
"repositorySetsDescription": "本工作區儲存庫的命名分組。在任務建立對話框中選擇儲存庫集會填充儲存庫選擇器,並將儲存的基礎分支複製到任務中;檢出分支仍按任務單獨設定。",
"repositorySetsCreate": "新建儲存庫集",
"repositorySetsCreateTitle": "新建儲存庫集",
"repositorySetsEditTitle": "編輯儲存庫集",
"repositorySetsEditorDescription": "選擇此儲存庫集包含的儲存庫,以及它們填充選擇器的順序。",
"repositorySetsEditorDescription": "選擇此儲存庫集包含的儲存庫、順序,以及每個儲存庫可選的基礎分支。",
"repositorySetsEdit": "編輯儲存庫集",
"repositorySetsDelete": "刪除儲存庫集",
"repositorySetsDeleteTitle": "刪除 {{name}}?",
@@ -151,15 +151,31 @@
"repositorySetsNameLabel": "名稱",
"repositorySetsDescriptionLabel": "描述(可選)",
"repositorySetsMembersLabel": "儲存庫",
"repositorySetsMembersHint": "請至少選擇一個。此處的順序即新增到任務時的順序。",
"repositorySetsMembersHint": "按任務順序新增儲存庫。基礎分支可選。",
"repositorySetsMoveUp": "上移",
"repositorySetsMoveDown": "下移",
"repositorySetsRemove": "移除儲存庫",
"repositorySetsSave": "儲存",
"repositorySetsNameTaken": "此工作區中已存在同名的儲存庫集。",
"repositorySetsMemberRejected": "所選儲存庫之一在此工作區中已不可用。",
"repositorySetsSaveFailed": "無法儲存該儲存庫集。請重試。",
"repositorySetsAlreadyDeleted": "該儲存庫集已被刪除。請刷新以查看目前列表。",
"repositorySetsDeleteFailed": "無法刪除該儲存庫集。請重試。",
"repositorySetsAddRepository": "新增儲存庫",
"repositorySetsSearchRepositories": "搜尋儲存庫…",
"repositorySetsNoMatchingRepositories": "沒有匹配的儲存庫。",
"repositorySetsSearchSelected": "篩選已選儲存庫…",
"repositorySetsNoMatchingMembers": "沒有選中的儲存庫匹配此搜尋。",
"repositorySetsNoMembersSelected": "尚未選擇儲存庫。",
"repositorySetsResetBases": "重置基礎分支",
"repositorySetsTaskDefault": "任務預設值({{branch}})",
"repositorySetsTaskDefaultNoBranch": "任務預設值",
"repositorySetsBaseBranchLabel": "儲存的基礎分支",
"repositorySetsBaseBranchFor": "{{name}} 的儲存基礎分支",
"repositorySetsBranchUnavailable": "不可用",
"repositorySetsBranchUnavailableReason": "此儲存的分支不可用。請選擇其他分支,或重置為任務預設值。",
"repositorySetsMemberMissing": "該儲存庫已不在此工作區中。",
"repositorySetsMemberBase": "基礎分支:{{base}}",
"branchPoliciesTitle": "分支策略",
"branchPoliciesDescription": "建立任務時選擇一個命名分支策略。所選策略會儲存到任務中。",
"branchPoliciesSaveRepositoryFirst": "請先儲存此儲存庫,再新增分支策略。",
+7 -1
View File
@@ -1,4 +1,6 @@
{
"repositorySetsSaveDuplicateRows_one": "不包含另外 {{count}} 行。每個儲存庫只儲存第一行及其基準分支選擇。",
"repositorySetsSaveDuplicateRows_other": "不包含另外 {{count}} 行。每個儲存庫只儲存第一行及其基準分支選擇。",
"agentCliNotInstalled": "未安裝代理程式 CLI",
"agentProbeFailed": "代理程式探測失敗",
"aPullRequestAlreadyExistsFor": "此任務已存在提取要求",
@@ -1213,7 +1215,7 @@
"repositoryWithPath": "儲存庫 · {{path}}",
"repositorySetsTrigger": "儲存庫集",
"repositorySetsSaveTitle": "另存為儲存庫集",
"repositorySetsSaveDescription": "在後續任務中複用此儲存庫選擇。分支按任務單獨選擇,不會被儲存。",
"repositorySetsSaveDescription": "在後續任務中複用此儲存庫選擇及其儲存的基礎分支。檢出分支仍按任務單獨設定。",
"repositorySetsSaveNameLabel": "名稱",
"repositorySetsSaveDescriptionLabel": "描述(可選)",
"repositorySetsSaveSubmit": "儲存",
@@ -1237,6 +1239,10 @@
"repositorySetsNothingToApply": "{{name}} 中的所有儲存庫都已在此任務中",
"repositorySetsSkippedMissing_one": "{{name}} 中有 {{count}} 個儲存庫已不存在於此工作區",
"repositorySetsSkippedMissing_other": "{{name}} 中有 {{count}} 個儲存庫已不存在於此工作區",
"repositorySetBaseUnavailable": "儲存的基礎分支不可用。請在建立任務前選擇其他分支。",
"repositorySetBaseForLocal": "基礎分支:{{base}}",
"branchUnavailable": "此儲存的分支不可用。請選擇其他分支。",
"branchUnavailableShort": "不可用",
"requestChanges": "請求更改",
"requestedChanges": "已請求更改",
"requestedChangesPlain": "{{who}} 請求了更改",
+19 -3
View File
@@ -136,11 +136,11 @@
"sourceFolderAlreadySelected": "該目錄已被選擇。",
"sourceRepoBranchAlreadySelected": "該儲存庫和分支已被選擇。",
"repositorySetsTitle": "儲存庫集",
"repositorySetsDescription": "本工作區儲存庫的命名分組。在任務建立對話方塊中選擇某個儲存庫集會用其成員填充儲存庫選擇器;分支仍按任務單獨選擇。",
"repositorySetsDescription": "本工作區儲存庫的命名分組。在任務建立對話方塊中選擇儲存庫集會填充儲存庫選擇器,並將儲存的基礎分支複製到任務中;檢出分支仍按任務單獨設定。",
"repositorySetsCreate": "新建儲存庫集",
"repositorySetsCreateTitle": "新建儲存庫集",
"repositorySetsEditTitle": "編輯儲存庫集",
"repositorySetsEditorDescription": "選擇此儲存庫集包含的儲存庫,以及它們填充選擇器的順序。",
"repositorySetsEditorDescription": "選擇此儲存庫集包含的儲存庫、順序,以及每個儲存庫可選的基礎分支。",
"repositorySetsEdit": "編輯儲存庫集",
"repositorySetsDelete": "刪除儲存庫集",
"repositorySetsDeleteTitle": "刪除 {{name}}?",
@@ -151,15 +151,31 @@
"repositorySetsNameLabel": "名稱",
"repositorySetsDescriptionLabel": "描述(可選)",
"repositorySetsMembersLabel": "儲存庫",
"repositorySetsMembersHint": "請至少選擇一個。此處的順序即新增到任務時的順序。",
"repositorySetsMembersHint": "按任務順序新增儲存庫。基礎分支可選。",
"repositorySetsMoveUp": "上移",
"repositorySetsMoveDown": "下移",
"repositorySetsRemove": "移除儲存庫",
"repositorySetsSave": "儲存",
"repositorySetsNameTaken": "此工作區中已存在同名的儲存庫集。",
"repositorySetsMemberRejected": "所選儲存庫之一在此工作區中已不可用。",
"repositorySetsSaveFailed": "無法儲存該儲存庫集。請重試。",
"repositorySetsAlreadyDeleted": "該儲存庫集已被刪除。請重新整理以檢視目前清單。",
"repositorySetsDeleteFailed": "無法刪除該儲存庫集。請重試。",
"repositorySetsAddRepository": "新增儲存庫",
"repositorySetsSearchRepositories": "搜尋儲存庫…",
"repositorySetsNoMatchingRepositories": "沒有匹配的儲存庫。",
"repositorySetsSearchSelected": "篩選已選儲存庫…",
"repositorySetsNoMatchingMembers": "沒有選中的儲存庫匹配此搜尋。",
"repositorySetsNoMembersSelected": "尚未選擇儲存庫。",
"repositorySetsResetBases": "重置基礎分支",
"repositorySetsTaskDefault": "任務預設值({{branch}})",
"repositorySetsTaskDefaultNoBranch": "任務預設值",
"repositorySetsBaseBranchLabel": "儲存的基礎分支",
"repositorySetsBaseBranchFor": "{{name}} 的已儲存基礎分支",
"repositorySetsBranchUnavailable": "不可用",
"repositorySetsBranchUnavailableReason": "此儲存的分支不可用。請選擇其他分支,或重置為任務預設值。",
"repositorySetsMemberMissing": "該儲存庫已不在此工作區中。",
"repositorySetsMemberBase": "基礎分支:{{base}}",
"branchPoliciesTitle": "分支策略",
"branchPoliciesDescription": "建立任務時選擇一個命名分支策略。所選策略會儲存到任務中。",
"branchPoliciesSaveRepositoryFirst": "請先儲存此儲存庫,再新增分支策略。",
@@ -46,9 +46,17 @@ jobs, and E2E README are implemented and verified.
node
`kindest/node:v1.34.8@sha256:02722c2dedddcfc00febf5d27fbeb9b7b2c14294c82109ff4a85d89ac9ba3256`.
- Runtime base:
`ubuntu:24.04@sha256:33ceb71981b602c1a7443a53469e4dba065f7503eab3078a2d7a57a2ab987517`.
Its `apt-get` package resolution is not snapshot-pinned; this is a documented
nonblocking reproducibility limitation for the fixture.
`ghcr.io/kdlbs/kandev-ci:runtime-sha-6f288a23c526@sha256:b9636e1c20adb0fcce1c65858a767fb1b0fe48a15ac5688b9c623163575efb8d`.
The compatibility fixture reuses the immutable CI runtime image, including
its lifecycle tools, instead of resolving Ubuntu packages on every runner.
### CI timeout remediation
The compatibility fixture now uses the pinned Kandev CI runtime image and the
container workflow pre-pulls it with bounded retries. The per-run image build
copies the current artifacts without contacting an Ubuntu package mirror. The
Kubernetes pin contract test covers the immutable image reference and the
absence of the package-install step.
### Fresh build identity
@@ -0,0 +1,155 @@
---
created: 2026-09-01
status: complete
requirements:
- REQ-WORKSPACES-REPOSITORY-SETS-001
- REQ-WORKSPACES-REPOSITORY-SETS-002
- REQ-WORKSPACES-REPOSITORY-SETS-003
system_design:
- ../../specs/workspaces/system-design/repository-sets.md
legacy_specs: []
---
# Implementation Plan: Repository Set Base Branches
## Overview
Add an optional base branch to each repository-set member. Then expose that
value in the approved inline editor and copy it into new task drafts.
The backend contract comes first because every later task consumes the member
shape. Task-draft behavior comes next because it defines local and worktree
semantics. The responsive editor, E2E evidence, and public documentation follow.
## Scope
### In scope
- Persist one optional base branch for each set member.
- Preserve `repository_ids` as a compatibility input.
- Show selected members and base selectors in one searchable editor.
- Load a member branch list only when its selector opens.
- Copy saved bases into new task rows without changing existing rows.
- Keep local base and checkout branches separate.
- Capture effective bases through `Save as set`.
- Cover desktop and phone workflows.
### Out of scope
- One bulk branch value for all members.
- Branch policies, generated branch templates, and pull-request targets in sets.
- Remote URLs, local folder sources, and Quick Chat.
- Automatic replacement of unavailable saved bases.
## Technical approach
### Persistence and service contract
Add `base_branch` to `repository_set_items` in the base schema and replayable
migrations. Extend models, repository scans, writes, DTOs, and event payloads.
Replace ordered ID slices inside the service with ordered member inputs. Keep
`repository_ids` at the handler boundary for compatibility. Reject requests
that contain both member fields.
Use `securityutil.IsValidBaseBranchRef` for each non-empty base. Do not query Git
inside set mutations.
### Task-draft behavior
Extend `TaskRepoRow` with explicit base state. Update `applyRepositorySet` to
copy a saved base only into a new row. Keep the idempotent skip rule for a row
that is already present.
Update task payload construction so worktree execution uses the effective base.
For local execution, send the effective base and checkout branch separately.
Update `Save as set` to send ordered member objects. The dialog summary states
that repository bases are included.
### Responsive settings editor
Replace the all-repository checkbox list with a selected-member list and an Add
repository picker. Keep explicit move actions for order.
Each row shows `Task default` or its saved base. Enable `useBranches` only
while that row selector is open. Keep an unavailable saved value visible.
Reuse New Task `Pill`, `sortBranches`, `branchOptionValue`, and `branchToOption` without a
settings-only fork. Preserve branch search, refresh, remote-qualified names,
and origin badges.
Use a bounded desktop dialog. Use the repository branch-policy full-height
drawer pattern on phones. The phone drawer owns one scroll region and a
safe-area footer.
### State, events, and localization
Extend HTTP, WebSocket, store, and boot-state types with `base_branch`. Add all
new copy to English, Portuguese, Simplified Chinese, and Traditional Chinese
catalogs. Generate both Traditional Chinese variants with the current script.
### Public documentation
Update the repository-set section in `docs/public/tasks-and-workflows.md`. State
how saved bases, task defaults, repository defaults, and unavailable branches
work.
## Tests
| Acceptance criteria | Evidence |
| ---------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------ |
| `AC-WORKSPACES-REPOSITORY-SETS-001.8`, `AC-WORKSPACES-REPOSITORY-SETS-002.1` | Repository and service tests for migration, ordered persistence, validation, and atomic replacement. |
| `AC-WORKSPACES-REPOSITORY-SETS-003.2` through `.8` | Pure apply tests and task-payload tests for idempotence, unavailable values, and local semantics. |
| `AC-WORKSPACES-REPOSITORY-SETS-002.2` through `.9` | Component tests for rows, shared branch search, origin labels, reset, lazy loading, and responsive surfaces. |
| `AC-WORKSPACES-REPOSITORY-SETS-003.9` | Save-as-set component and API tests for ordered member objects. |
## E2E tests
- Desktop settings creates and edits saved bases in
`apps/web/e2e/tests/settings/workspace-repository-sets.spec.ts`. The test also
proves search, remote-qualified names, origin badges, and refresh.
- Desktop task creation applies those bases in
`apps/web/e2e/tests/task/create-task-repository-sets.spec.ts`.
- Phone settings proves the full-height editor, internal scroll, safe footer,
touch targets, and selector containment in
`mobile-workspace-repository-sets.spec.ts`.
- Phone task creation proves the same saved-base outcome in
`mobile-create-task-repository-sets.spec.ts`.
## Work orders
- [x] [Task 01: Persist member base branches](task-01-persist-member-base-branches.md)
- [x] [Task 02: Apply saved bases to task drafts](task-02-apply-saved-bases.md)
- [x] [Task 03: Build the responsive inline editor](task-03-responsive-inline-editor.md)
- [x] [Task 04: Prove saved-base workflows](task-04-saved-base-e2e.md)
- [x] [Task 05: Document repository-set bases](task-05-public-documentation.md)
## Verification results
- Backend focused packages pass: `go test ./internal/task/repository/sqlite
./internal/task/service ./internal/task/dto ./internal/task/handlers
./internal/backendapp`.
- Frontend focused tests pass: 8 files, 103 tests. The task-draft and editor
work-order commands also pass independently with 82 and 46 tests.
- `pnpm run typecheck`, `pnpm --filter @kandev/web lint`, `pnpm run i18n:check`,
`pnpm run i18n:ratchet`, and the changed-file E2E lint pass.
- `make build-web` passes. Desktop repository-set E2E passes 7 tests, and mobile
repository-set E2E passes 3 tests.
- Public documentation validation passes 61 tests and validates 41 published
pages. Specification lint passes all files.
- The full-repository `lint:e2e-sleeps` command still reports unrelated existing
errors in other files; the five changed E2E files pass its rule directly.
## Risks
- The current task row overloads one branch value across executor types. The
implementation must not map a saved base to a local checkout.
- Large sets can cause many Git requests if branch hooks become active during
row render. Selector-open state must gate each request.
- SQLite and PostgreSQL both use the repository-set schema path. The migration
and scans must change together.
- Old clients send `repository_ids`. Removing this input can break scripts and
existing E2E helpers.
- An unavailable branch can persist after a remote deletion. The UI must keep
the value visible and block unintended substitution.
@@ -0,0 +1,94 @@
---
id: "01-persist-member-base-branches"
title: "Persist member base branches"
status: done
wave: 1
depends_on: []
plan: "plan.md"
requirements:
- REQ-WORKSPACES-REPOSITORY-SETS-001
- REQ-WORKSPACES-REPOSITORY-SETS-002
acceptance_criteria:
- AC-WORKSPACES-REPOSITORY-SETS-001.8
- AC-WORKSPACES-REPOSITORY-SETS-002.1
system_design:
- ../../specs/workspaces/system-design/repository-sets.md
---
# Task 01: Persist Member Base Branches
## Summary
Add an optional base branch to each persisted set member. Expose the value
through service, HTTP, WebSocket, event, and boot-state contracts.
## In scope
- Add the replay-safe SQLite and PostgreSQL column migration.
- Update repository-set models, scans, inserts, and replacements.
- Accept ordered member objects and validate non-empty bases.
- Keep `repository_ids` as a no-base compatibility input.
- Include `base_branch` in DTOs and semantic events.
## Out of scope
- Frontend state and UI behavior.
- Git branch existence requests during mutations.
- Changes to repository defaults or task rows.
## Acceptance
- Existing member rows migrate with an empty base and keep their order.
- Create and update persist every member base atomically.
- Unsafe bases and conflicting input shapes produce no write.
## Verification
Run this command from `apps/backend`.
```bash
go test ./internal/task/repository/sqlite ./internal/task/service ./internal/task/dto ./internal/task/handlers ./internal/backendapp
```
## Files likely touched
- `apps/backend/internal/task/models/models.go`
- `apps/backend/internal/task/repository/interface.go`
- `apps/backend/internal/task/repository/sqlite/base_schema.go`
- `apps/backend/internal/task/repository/sqlite/base_migrations.go`
- `apps/backend/internal/task/repository/sqlite/repository_set.go`
- `apps/backend/internal/task/service/service_repository_sets.go`
- `apps/backend/internal/task/dto/dto.go`
- `apps/backend/internal/task/handlers/repository_set_handlers.go`
- `apps/backend/internal/backendapp/boot_state_routes.go`
- Existing repository-set tests beside these files
## Dependencies
None.
## Risks
- A schema query can reference the new column before the migration adds it.
- A compatibility request can conflict with the new ordered member field.
## Parallelism
`sequential`
## Inputs
- `REQ-WORKSPACES-REPOSITORY-SETS-001`
- `REQ-WORKSPACES-REPOSITORY-SETS-002`
- System-design sections for data, persistence, service, and events
## Results
- Added `repository_set_items.base_branch` to the fresh schema and replayable
migration, with SQLite legacy-row and PostgreSQL parity coverage.
- Persisted ordered member bases through repository, service, DTO, HTTP, WebSocket,
boot-state, and repository-set event paths.
- Preserved `repository_ids` compatibility input and rejected unsafe or conflicting
member payloads atomically.
- Verification: `go test ./internal/task/repository/sqlite ./internal/task/service
./internal/task/dto ./internal/task/handlers ./internal/backendapp` (3848 passed).
@@ -0,0 +1,104 @@
---
id: "02-apply-saved-bases"
title: "Apply saved bases to task drafts"
status: done
wave: 2
depends_on:
- "01-persist-member-base-branches"
plan: "plan.md"
requirements:
- REQ-WORKSPACES-REPOSITORY-SETS-003
acceptance_criteria:
- AC-WORKSPACES-REPOSITORY-SETS-003.2
- AC-WORKSPACES-REPOSITORY-SETS-003.3
- AC-WORKSPACES-REPOSITORY-SETS-003.4
- AC-WORKSPACES-REPOSITORY-SETS-003.5
- AC-WORKSPACES-REPOSITORY-SETS-003.6
- AC-WORKSPACES-REPOSITORY-SETS-003.7
- AC-WORKSPACES-REPOSITORY-SETS-003.8
- AC-WORKSPACES-REPOSITORY-SETS-003.9
system_design:
- ../../specs/workspaces/system-design/repository-sets.md
---
# Task 02: Apply Saved Bases To Task Drafts
## Summary
Carry member bases through frontend types and set application. Keep the base
separate from local checkout state during task submission.
## In scope
- Extend frontend HTTP, WebSocket, and store member types.
- Copy saved bases into new task rows.
- Preserve existing rows during repeated or overlapping application.
- Block silent fallback for an unavailable copied base.
- Send separate base and checkout values for local execution.
- Make `Save as set` send ordered members with effective bases.
## Out of scope
- Settings editor composition.
- Translation catalogs except copy owned by `Save as set`.
- Browser E2E coverage.
## Acceptance
- Applying a set copies each available saved base into a new row.
- Existing rows remain unchanged and duplicate application stays idempotent.
- Local payloads never use a copied base as checkout without user action.
## Verification
Run this command from `apps/web`.
```bash
pnpm exec vitest run lib/api/domains/repository-sets-api.test.ts lib/ws/handlers/repository-sets.test.ts lib/state/slices/workspace/repository-sets-slice.test.ts components/task-create-dialog-repository-sets.test.ts components/task-create-dialog-helpers.multi-repo.test.ts components/task-create-dialog-repository-sets-save.test.tsx
```
## Files likely touched
- `apps/web/lib/types/http.ts`
- `apps/web/lib/types/backend.ts`
- `apps/web/lib/api/domains/workspace-api.ts`
- `apps/web/lib/ws/handlers/repository-sets.ts`
- `apps/web/lib/state/slices/workspace/workspace-slice.ts`
- `apps/web/components/task-create-dialog-types.ts`
- `apps/web/components/task-create-dialog-repository-sets.ts`
- `apps/web/components/task-create-dialog-repository-sets-apply.ts`
- `apps/web/components/task-create-dialog-repository-sets-save.tsx`
- `apps/web/components/task-create-dialog-helpers.ts`
- Related unit tests
## Dependencies
- Task 01 supplies the member transport shape.
## Risks
- Existing auto-selection can overwrite a copied saved base.
- Local and worktree executors interpret the current `branch` field differently.
## Parallelism
`sequential`
## Inputs
- `REQ-WORKSPACES-REPOSITORY-SETS-003`
- System-design sections for task application and local executor semantics
- Existing pure apply tests and multi-repository payload tests
## Results
- Copied saved member bases into new task rows while preserving empty branch
state for local checkout semantics.
- Kept application additive and idempotent, blocked unavailable copied bases,
and preserved separate local base and checkout payload values.
- Updated Save as set to send ordered member objects with effective bases.
- Review follow-up: Save as set reports duplicate rows separately from
non-workspace rows. It explains that the first row and its base choice win.
Focused tests cover singular/plural counts, mixed exclusions, unchanged drafts,
and first-row bases in worktree, local, and fresh-branch modes.
- Verification: the work-order Vitest command passed 6 files and 82 tests.
@@ -0,0 +1,135 @@
---
id: "03-responsive-inline-editor"
title: "Build the responsive inline editor"
status: done
wave: 3
depends_on:
- "02-apply-saved-bases"
plan: "plan.md"
requirements:
- REQ-WORKSPACES-REPOSITORY-SETS-002
- REQ-WORKSPACES-REPOSITORY-SETS-003
acceptance_criteria:
- AC-WORKSPACES-REPOSITORY-SETS-002.2
- AC-WORKSPACES-REPOSITORY-SETS-002.3
- AC-WORKSPACES-REPOSITORY-SETS-002.4
- AC-WORKSPACES-REPOSITORY-SETS-002.5
- AC-WORKSPACES-REPOSITORY-SETS-002.6
- AC-WORKSPACES-REPOSITORY-SETS-002.7
- AC-WORKSPACES-REPOSITORY-SETS-002.8
- AC-WORKSPACES-REPOSITORY-SETS-002.9
- AC-WORKSPACES-REPOSITORY-SETS-003.9
system_design:
- ../../specs/workspaces/system-design/repository-sets.md
---
# Task 03: Build The Responsive Inline Editor
## Summary
Build the approved selected-member editor with one base selector per row. Use a
desktop dialog and a full-height phone drawer with shared form state.
## In scope
- Show searchable and ordered selected-member rows.
- Add repositories through a searchable picker.
- Add per-row default and saved-base selectors.
- Reuse New Task branch search, refresh, option mapping, and origin badges.
- Gate branch loading on selector-open state.
- Keep unavailable saved values visible.
- Add Reset bases and responsive action regions.
- Add localized copy in every supported catalog.
## Out of scope
- Backend transport changes.
- Task payload construction.
- Playwright coverage.
## Acceptance
- Opening a large set starts no branch request until a selector opens.
- Branch rows match New Task names, search results, ordering, and origin badges.
- Desktop and phone surfaces expose the same member and base actions.
- The phone editor has one scroll owner, safe-area actions, and touch targets of
at least 44 CSS pixels.
## Verification
Run these commands from `apps/web`.
```bash
pnpm exec vitest run app/settings/workspace/workspace-repository-set-editor.test.tsx app/settings/workspace/workspace-repository-sets-section.test.tsx components/task-create-dialog-selectors.test.tsx components/combobox.test.tsx components/task-create-dialog-repository-sets-save.test.tsx
pnpm run typecheck
pnpm run i18n:check
```
## Files likely touched
- `apps/web/app/settings/workspace/workspace-repository-set-editor.tsx`
- `apps/web/app/settings/workspace/workspace-repository-sets-section.tsx`
- `apps/web/app/settings/workspace/use-workspace-repository-sets.ts`
- `apps/web/components/task-create-dialog-selectors.tsx`
- `apps/web/components/task-create-dialog-branch-options.tsx`
- `apps/web/components/combobox.tsx`
- `apps/web/components/settings/repository-branch-policy-fields.tsx`
- `apps/web/src/locales/en/workspaces.json`
- `apps/web/src/locales/pt-pt/workspaces.json`
- `apps/web/src/locales/zh-cn/workspaces.json`
- `apps/web/src/locales/zh-hk/workspaces.json`
- `apps/web/src/locales/zh-tw/workspaces.json`
- Related component tests
## Dependencies
- Task 02 supplies the frontend member and task-draft shapes.
## Risks
- A hook per visible row can cause eager Git traffic.
- A settings-only option mapper can drift from New Task labels and filtering.
- Nested selectors can create more than one scroll owner on phones.
- Saved unavailable values need a stable fallback option.
## Parallelism
`sequential`
## Inputs
- `REQ-WORKSPACES-REPOSITORY-SETS-002`
- Mobile design contract in the repository-set system design
- New Task `Pill`, `branchOptionValue`, `branchToOption`, and `sortBranches`
- Repository branch-policy dialog and drawer patterns
## Results
- Scroll follow-up: the desktop wheel regression reproduced a branch list that
stayed at scrollTop zero. The editor now supplies its outer surface as the
shared picker portal container and allows popup overflow outside the form.
The member hint is shorter and remains visible on desktop and mobile.
Added desktop wheel and mobile touch regressions with 40 branches.
Validation passed: 47 focused component tests, 2 desktop and 3 mobile E2E
tests, TypeScript checking, changed-file ESLint, i18n checks, and spec lint.
- UX follow-up: use the shared 28px desktop control sizing and responsive
touch targets for add, base, reset, and row actions. Keep the existing
full-height phone drawer and shared state. Portal the repository picker out
of the scrolling form and align it to the trigger's trailing edge.
Pad the desktop footer and override the shared bottom drawer's 80vh cap so
the phone editor retains its full-height contract.
Verification: 25 focused component tests and both settings Playwright specs
pass (2 desktop and 2 mobile tests). The browser checks cover 28px desktop
controls, repository-option hit targets, phone touch sizes, and drawer height.
- Replaced the checkbox editor with ordered selected-member rows, searchable
add and filter controls, move/remove actions, per-member base selectors, and
Reset bases.
- Reused New Task branch options and loading behavior, including unavailable
saved values and open-triggered branch requests.
- Added the bounded desktop dialog and full-height phone drawer with one scroll
owner, safe-area footer, and touch-sized actions.
- Added complete localized copy across English, Portuguese, Simplified Chinese,
Traditional Chinese, and the pseudo catalog.
- Verification: the work-order Vitest command passed 5 files and 46 tests;
typecheck, web lint, i18n check, and i18n ratchet pass.
@@ -0,0 +1,107 @@
---
id: "04-saved-base-e2e"
title: "Prove saved-base workflows"
status: done
wave: 4
depends_on:
- "03-responsive-inline-editor"
plan: "plan.md"
requirements:
- REQ-WORKSPACES-REPOSITORY-SETS-002
- REQ-WORKSPACES-REPOSITORY-SETS-003
acceptance_criteria:
- AC-WORKSPACES-REPOSITORY-SETS-002.2
- AC-WORKSPACES-REPOSITORY-SETS-002.5
- AC-WORKSPACES-REPOSITORY-SETS-002.7
- AC-WORKSPACES-REPOSITORY-SETS-002.8
- AC-WORKSPACES-REPOSITORY-SETS-002.9
- AC-WORKSPACES-REPOSITORY-SETS-003.4
- AC-WORKSPACES-REPOSITORY-SETS-003.6
- AC-WORKSPACES-REPOSITORY-SETS-003.9
system_design:
- ../../specs/workspaces/system-design/repository-sets.md
---
# Task 04: Prove Saved-Base Workflows
## Summary
Extend repository-set Playwright coverage for saved bases. Prove the same user
outcome on desktop and phone layouts.
## In scope
- Create and edit saved member bases in repository settings.
- Apply a configured set in New Task and create the task.
- Prove persisted task-repository bases.
- Prove phone drawer containment, scrolling, safe footer, and touch targets.
- Prove branch search, refresh, remote-qualified names, and origin badges.
- Prove that large-set editor open does not start branch requests for every row.
## Out of scope
- Full E2E suite execution.
- Remote URL and Quick Chat flows.
- Visual asset capture.
## Acceptance
- Desktop creates a task with the configured base for each set member.
- Settings uses the same searchable and origin-labeled branch rows as New Task.
- Phone settings and task creation produce the same saved-base result.
- Phone tests show no document-level horizontal overflow.
## Verification
Run `make build-web` from the repository root. Run the `pnpm` commands from
`apps/web`.
```bash
make build-web
```
```bash
pnpm e2e:raw --project=chromium e2e/tests/settings/workspace-repository-sets.spec.ts e2e/tests/task/create-task-repository-sets.spec.ts
pnpm e2e:raw --project=mobile-chrome e2e/tests/settings/mobile-workspace-repository-sets.spec.ts e2e/tests/task/mobile-create-task-repository-sets.spec.ts
```
## Files likely touched
- `apps/web/e2e/helpers/api-client.ts`
- `apps/web/e2e/tests/settings/workspace-repository-sets.spec.ts`
- `apps/web/e2e/tests/settings/mobile-workspace-repository-sets.spec.ts`
- `apps/web/e2e/tests/task/create-task-repository-sets.spec.ts`
- `apps/web/e2e/tests/task/mobile-create-task-repository-sets.spec.ts`
## Dependencies
- Task 03 completes the responsive UI and task workflow.
## Risks
- E2E branch fixtures need distinct branches in every test repository.
- A stale web build can hide frontend changes.
## Parallelism
`sequential`
## Inputs
- Desktop and mobile repository-set E2E patterns
- Mobile layout assertions
- System-design verification strategy
## Results
- Desktop settings and task-create coverage verifies saved-base editing,
application, task persistence, idempotence, and Save as set behavior.
- Desktop settings coverage opens the shared New Task branch picker and verifies
grouped search, refresh, local and remote-qualified values, and origin badges.
- Phone settings coverage verifies the full-height drawer, internal scroll,
safe-area action footer, touch targets, picker search/refresh, remote badges,
viewport containment, and no document overflow.
- Phone task-create coverage verifies the same saved base reaches the created
task repository.
- Verification: `make build-web`, desktop E2E (8 tests), and mobile E2E (4
tests) pass.
@@ -0,0 +1,87 @@
---
id: "05-public-documentation"
title: "Document repository-set bases"
status: done
wave: 5
depends_on:
- "04-saved-base-e2e"
plan: "plan.md"
requirements:
- REQ-WORKSPACES-REPOSITORY-SETS-002
- REQ-WORKSPACES-REPOSITORY-SETS-003
acceptance_criteria:
- AC-WORKSPACES-REPOSITORY-SETS-002.1
- AC-WORKSPACES-REPOSITORY-SETS-002.3
- AC-WORKSPACES-REPOSITORY-SETS-002.6
- AC-WORKSPACES-REPOSITORY-SETS-003.3
- AC-WORKSPACES-REPOSITORY-SETS-003.4
- AC-WORKSPACES-REPOSITORY-SETS-003.7
system_design:
- ../../specs/workspaces/system-design/repository-sets.md
---
# Task 05: Document Repository-Set Bases
## Summary
Update the public repository-set guide after the verified behavior exists. Use
the same terms as the settings and task-create interfaces.
## In scope
- Explain saved bases, `Task default`, and the repository-default context.
- Explain apply-time copy and existing-row preservation.
- Explain unavailable saved bases and user repair.
- Update API payload examples for ordered member objects and compatibility IDs.
## Out of scope
- General Git branch-policy documentation.
- Release notes and marketing copy.
## Acceptance
- The guide no longer states that sets hold repositories only.
- The guide distinguishes task defaults, repository defaults, saved bases, and
local checkout.
- The API example includes the new ordered member field and compatibility note.
## Verification
Run these commands from the repository root.
```bash
git diff --check -- docs/public/tasks-and-workflows.md
rg -n "repository set|saved base|Task default|repository_ids" docs/public/tasks-and-workflows.md
```
## Files likely touched
- `docs/public/tasks-and-workflows.md`
## Dependencies
- Task 04 proves the final visible behavior.
## Risks
- The guide can describe planned UI instead of the implemented UI if this task
starts before E2E completion.
## Parallelism
`sequential`
## Inputs
- `REQ-WORKSPACES-REPOSITORY-SETS-002`
- `REQ-WORKSPACES-REPOSITORY-SETS-003`
- Verified UI labels from Task 04
## Results
- Updated the public task guide with saved bases, Task default and repository
default context, local checkout separation, unavailable-branch recovery, and
ordered API member objects with `repository_ids` compatibility.
- Verification: the public-docs validator passed 61 tests, all 41 published
pages validated, and the task-specific diff and search checks pass.
+44 -30
View File
@@ -83,7 +83,7 @@ Use **New Task** in the sidebar. In an open task, the **Task** split button also
| Source | Use it for | Important behavior |
| ---------- | ------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Repo** | A configured, discovered, or new local repository | Select a named branch policy or a raw base branch for each repository row. A policy creates a fresh branch from its saved base and uses its branch template. For a single-row new task, **Create new repository** initializes an empty `main` repository with one empty initial commit in a parent folder you choose. Add more rows for a multi-repository task. |
| **Repo** | A configured, discovered, or new local repository | Select a named branch policy or a raw base branch for each repository row. A policy creates a fresh branch from its saved base and uses its branch template. For a single-row new task, **Create new repository** initializes an empty `main` repository with one empty initial commit in a parent folder you choose. Add more rows for a multi-repository task. |
| **Remote** | A remote repository | Search configured GitHub, GitLab, or Azure DevOps repositories, or paste a supported URL. A pasted URL stays editable until you press Enter; then select the branch. Anonymous, credential-free reads include public GitHub repository branches, pull requests, and issues, plus public `gitlab.com` branch discovery. Private resources and authenticated browse/write features require valid provider credentials. |
| **None** | Planning, research, or work outside Git | Use a scratch workspace or an optional folder on the Kandev host. Git worktree execution and repository-aware Changes, branch, and pull-request features are unavailable. |
@@ -178,7 +178,7 @@ For a compact reminder while you read later replies, enable **Show anchored
prompt bar** in the same settings section. On desktop, it pins a shortened
copy of your latest prompt below the session tabs once you've scrolled past
it further down the transcript. It stays hidden while you're browsing earlier
history above your prompt, even though the prompt itself is out of view.
history above your prompt, even though the prompt itself is out of view.
use **Scroll to last prompt** to jump back to it instead. Expand the bar for
longer prompts, or use its scroll action to return to the full prompt; the
expanded view is capped at 40% of the transcript panel's height so it stays
@@ -207,17 +207,22 @@ A **repository set** is a named, reusable group of a workspace's repositories: d
once, then fill the repository picker with all of its repositories in a single action every time that
combination of repositories is the one you need.
A set holds repositories only. Branches stay a per-task decision, so applying a set leaves each row's
branch to the picker's normal defaulting, and you review and adjust branches exactly as when adding
rows by hand.
A set stores the repositories and order, plus an optional saved base branch for each member. When a
member has no saved base, applying the set uses the task form's normal defaulting. A saved base is
copied into the new task row, while the task branch or local checkout remains a separate choice.
Define a set in either place:
- **Settings → Workspaces → _workspace_ → Repositories**, in the **Repository sets** section: create,
rename, edit which repositories belong, reorder them, and delete.
rename, add or remove repositories, set each member's base branch, reorder them, reset saved bases,
and delete.
- **New Task → Sets → Save as set**, which captures the repositories currently selected in the form
without disturbing the task you are creating.
Each set contains a repository only once. If the form contains several rows for one repository,
**Save as set** keeps the first row and its base choice. The dialog reports additional rows separately
from rows that are not workspace repositories. The task draft keeps all its rows.
Apply one from the **Sets** control beside **add repository** in **New Task** and **New subtask**.
Applying a set adds one row per repository, in the set's order. It is additive and repeatable:
@@ -230,21 +235,30 @@ Applying a set adds one row per repository, in the set's order. It is additive a
Applying a set only fills the form. Nothing is saved until you create the task, so the repositories
the task ends up with are whatever the form holds when you submit.
Each set member's base selector includes **Task default**, with the repository's current default branch
shown as context when available. Open a selector to load its branch list. If a saved branch no longer
exists, the form keeps that value visible as unavailable and blocks task creation until you select an
available branch or **Task default**. For local execution, the saved base is used separately from the
repository's current checkout branch.
Sets are also available over the API for scripted setup:
```text
GET /api/v1/workspaces/:id/repository-sets
POST /api/v1/workspaces/:id/repository-sets {"name","description","repository_ids"}
POST /api/v1/workspaces/:id/repository-sets {"name","description","repositories"}
GET /api/v1/repository-sets/:id
PATCH /api/v1/repository-sets/:id any of name, description, repository_ids
PATCH /api/v1/repository-sets/:id any of name, description, repositories
DELETE /api/v1/repository-sets/:id
```
`repository_ids` is ordered and is the order a set fills the picker. A supplied `repository_ids`
replaces the whole membership list, which is also how you reorder one; omit the field to leave
membership untouched. The same five operations exist as `repository_set.list|create|get|update|delete`
WebSocket actions, and `repository_set.created|updated|deleted` notifications keep every open client
current. See [WebSocket API](websocket-api.md).
`repositories` is an ordered list of objects. Each object has a `repository_id` and an optional
`base_branch`; an empty or omitted base uses task defaulting. A supplied list replaces the whole
membership list, which is also how you reorder one. Omit the field to leave membership untouched.
Existing clients may send ordered `repository_ids`; those members have no saved bases. Do not send both
member fields in one request. The same five operations exist as
`repository_set.list|create|get|update|delete` WebSocket actions, and
`repository_set.created|updated|deleted` notifications keep every open client current. See
[WebSocket API](websocket-api.md).
Sets are workspace-scoped and shared: everyone who can see the workspace sees and can apply its sets.
A set name is unique within its workspace, compared case-insensitively. Deleting a set removes the
@@ -430,9 +444,9 @@ Explicit view selections (including Kanban, Pipeline, and List), task, session,
The **TASKS** list in the left sidebar has two time-based sort choices. These choices are separate from the sort choices in the task **List** view.
| Sort choice | Meaning |
| --- | --- |
| **Updated** | The last task summary refresh. Background events, such as pull-request status changes, can change this time. |
| Sort choice | Meaning |
| ----------------- | ----------------------------------------------------------------------------------------------------------------------- |
| **Updated** | The last task summary refresh. Background events, such as pull-request status changes, can change this time. |
| **Last activity** | The last real user or agent action. Opening or focusing a task and background provider polling do not change this time. |
Choose **Last activity** when you want to review tasks by the least recent user or agent interaction.
@@ -550,12 +564,12 @@ Pull configuration rejects self-references, cycles, and cross-workflow feeders.
### Configure events and transitions
| Event | Available transition |
| ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| **On Turn Start** | Do nothing, move next, move previous, or move to a selected step when the user sends a message. |
| **On Turn Complete** | **Do nothing (wait for user)**, move next, move previous, or move to a selected step after the agent turn. |
| Event | Available transition |
| ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| **On Turn Start** | Do nothing, move next, move previous, or move to a selected step when the user sends a message. |
| **On Turn Complete** | **Do nothing (wait for user)**, move next, move previous, or move to a selected step after the agent turn. |
| **Cancelled turn completion** | When enabled, an explicit user cancellation also runs this step's normal `on_turn_complete` actions after the cancelled turn settles. It bypasses the `auto_advance_requires_signal` / `step_complete_kandev` gate for that cancellation, but a pending clarification still blocks the transition. It does not apply to silent clarification cancellation, peer interruptions, parent/task stops, provider errors, crashes, or runtime teardown. |
| **When Child Tasks Complete** | Do nothing, move next, move previous, or move to a selected step after every active direct child reaches `COMPLETED`, `FAILED`, or `CANCELLED`, provided the parent has an active session. |
| **When Child Tasks Complete** | Do nothing, move next, move previous, or move to a selected step after every active direct child reaches `COMPLETED`, `FAILED`, or `CANCELLED`, provided the parent has an active session. |
The child-completion event ignores archived and ephemeral children, does not inspect grandchildren, and does nothing when the parent has no children. It also requires a parent session in `CREATED`, `STARTING`, `RUNNING`, or `WAITING_FOR_INPUT`; a parent with no session, or only an `IDLE`, `COMPLETED`, `FAILED`, or `CANCELLED` session, does not transition.
@@ -665,12 +679,12 @@ Revision history is not an immutable record of every autosave. Consecutive write
> [!EXPERIMENTAL]
> Office is feature-flagged, disabled in the production profile by default, and still in progress. Its named documents, labels, and blocker controls are not stable regular-Kanban features.
| Capability | Regular Kanban | Office |
| ------------------------------------- | -------------- | --------------------------------------------------- |
| One versioned task plan | Available | Available in Office-specific surfaces where enabled |
| Multiple named task documents | Not exposed | In-progress Office capability |
| Task label editor and label filters | Not exposed | In-progress Office capability |
| Blocked-by / blocking property editor | Set at task creation or over MCP; read-only afterwards | In-progress Office capability |
| Capability | Regular Kanban | Office |
| ------------------------------------- | ------------------------------------------------------ | --------------------------------------------------- |
| One versioned task plan | Available | Available in Office-specific surfaces where enabled |
| Multiple named task documents | Not exposed | In-progress Office capability |
| Task label editor and label filters | Not exposed | In-progress Office capability |
| Blocked-by / blocking property editor | Set at task creation or over MCP; read-only afterwards | In-progress Office capability |
Regular Kanban reads and enforces blocker relationships (see [Task dependencies](#task-dependencies)) but has no blocker filter and no in-place editor: dependencies are declared when the task is created or over MCP. Office additionally exposes named documents, labels, and its own blocker property editor. Do not treat those Office surfaces as a stable public contract yet.
@@ -678,9 +692,9 @@ Regular Kanban reads and enforces blocker relationships (see [Task dependencies]
Archive records the task as archived and removes it from active views immediately. Runtime stopping and physical cleanup then run in the background with a 60-second timeout. Cleanup is best-effort: a stop or deletion failure is logged and does not undo the archive, and Kandev preserves a runtime or environment when a nonterminal session cannot be stopped. Shared inherited environments and borrowed worktrees are also preserved while another active task still uses them.
| Executor | Archive cleanup |
| ------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Local | Attempts to stop the agent runtime; leaves the local folder, files, and branch untouched. |
| Executor | Archive cleanup |
| ------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Local | Attempts to stop the agent runtime; leaves the local folder, files, and branch untouched. |
| Git worktree | Attempts to remove the Kandev-owned worktree directory. It keeps the local task branch and leaves any existing remote branch untouched. Shared or borrowed worktrees can remain until their last active user is gone. |
| Local Docker | Attempts to stop and remove the container; the host repository remains. |
| Kubernetes | Deletes only the recorded Pod and Kandev-managed PVC after exact UID and ownership checks. An existing claim is retained. |
+1 -2
View File
@@ -30,8 +30,6 @@ Git state.
### Requirements
- [Branch Policies](requirements/branch-policies.md)
- [Create a Local Repository During Task Creation](requirements/create-local-repository.md)
- [Empty Remote Repositories](requirements/empty-remote-repositories.md)
@@ -57,6 +55,7 @@ Git state.
- [Worktree Base Refresh](system-design/worktree-base-refresh.md)
- [Local Workspace Repositories](system-design/local-repositories.md)
- [Organization Units](system-design/org-units.md)
- [Repository Sets](system-design/repository-sets.md)
## Migration record
@@ -5,236 +5,155 @@ created: 2026-08-17
owners:
- kandev
---
# Repository Sets Requirements
## Overview
A workspace can hold dozens of repositories, and real work rarely touches one of them. A feature that runs from a web client through an API gateway into two backend services means the same four repositories are picked, one chip at a time, every time a task is created. The selection is knowledge the user already has and re-enters by hand on every task, and a repository forgotten on the third of four chips is only discovered once the agent cannot find the code.
A workspace can contain many repositories. Most tasks use a known subset of
those repositories. A repository set stores that reusable selection and its
order.
Some teams also start work from different base branches in each repository.
The set can store an optional base-branch preference for each member. This
preference reduces repeated task setup without changing repository defaults.
The workspace system owns this contract because it owns repository membership,
repository settings, and repository branch information.
## Terminology
- **Repository set:** A named and ordered group of repositories in one
workspace.
- **Set member:** One repository and its position in a repository set.
- **Saved base:** An optional base branch on a set member.
- **Task default:** The existing task-form branch selection when a set has no
saved base.
- **Repository default:** The current `default_branch` of a repository.
## Requirements
### REQ-WORKSPACES-REPOSITORY-SETS-001: Repository Sets
### REQ-WORKSPACES-REPOSITORY-SETS-001: Repository set management
**Intent:** A workspace can hold dozens of repositories, and real work rarely touches one of them. A feature that runs from a web client through an API gateway into two backend services means the same four repositories are picked, one chip at a time, every time a task is created. The selection is knowledge the user already has and re-enters by hand on every task, and a repository forgotten on the third of four chips is only discovered once the agent cannot find the code.
**Intent:** Users can reuse a known repository selection when they create a
task.
#### Acceptance criteria
- **AC-WORKSPACES-REPOSITORY-SETS-001.1:** A workspace owns any number of repository sets. A set has a name, an optional description, and an ordered list of repositories drawn from that same workspace.
- **AC-WORKSPACES-REPOSITORY-SETS-001.2:** Sets are managed from **Settings > Workspaces > _workspace_ > Repositories**, in a **Repository sets** section below the repository list. The section lists each set with its name, description, and member repositories, and offers create, rename, edit-members, reorder, and delete.
- **AC-WORKSPACES-REPOSITORY-SETS-001.3:** Sets can also be created from the task-creation dialog: **Save as set** captures the workspace repositories currently selected in the form, asks for a name, and creates the set without discarding the in-progress task.
- **AC-WORKSPACES-REPOSITORY-SETS-001.4:** A set name is trimmed, is 1 to 100 characters, and is unique within its workspace, compared case-insensitively. A second set named `Full-stack` in a workspace that already has `full-stack` is rejected and the existing set is named in the error.
- **AC-WORKSPACES-REPOSITORY-SETS-001.5:** A set holds each repository at most once. Ordering is user-controlled and is the order in which the set fills the picker.
- **AC-WORKSPACES-REPOSITORY-SETS-001.6:** Creating or updating a set requires at least one repository. A set that becomes empty because its repositories were deleted is kept, not removed.
- **AC-WORKSPACES-REPOSITORY-SETS-001.7:** Sets are workspace-scoped, not per-user: every user who can see the workspace sees and can apply its sets.
- **AC-WORKSPACES-REPOSITORY-SETS-001.8:** The repository row of the task-creation dialog gains a **Sets** control next to **add repository**, listing the workspace's sets by name with their repository count.
- **AC-WORKSPACES-REPOSITORY-SETS-001.1:** A workspace shall own any number of
repository sets. Each set shall have a name, an optional description, and an
ordered list of repositories from that workspace.
- **AC-WORKSPACES-REPOSITORY-SETS-001.2:** The repository settings page shall
list each set with its name, description, and members. Users shall be able to
create, rename, reorder, edit, and delete sets.
- **AC-WORKSPACES-REPOSITORY-SETS-001.3:** A set name shall contain 1 to 100
characters after trimming. Names shall be unique in a workspace under a
case-insensitive comparison.
- **AC-WORKSPACES-REPOSITORY-SETS-001.4:** A set shall contain each repository
at most once. A user-created set shall contain at least one repository.
- **AC-WORKSPACES-REPOSITORY-SETS-001.5:** When repository deletion removes the
last member, the system shall keep the empty set.
- **AC-WORKSPACES-REPOSITORY-SETS-001.6:** Every user with workspace access
shall see the same sets. Workspace write permissions shall control set
mutations.
- **AC-WORKSPACES-REPOSITORY-SETS-001.7:** A mutation shall update other open
clients through the repository-set event stream.
- **AC-WORKSPACES-REPOSITORY-SETS-001.8:** Set writes shall be atomic. A
rejected name, member, order, or saved base shall not cause a partial write.
## Migrated source detail
### REQ-WORKSPACES-REPOSITORY-SETS-002: Saved base branches
## Why
**Intent:** Users can reuse the base branch for each repository in a set.
A workspace can hold dozens of repositories, and real work rarely touches one of them. A feature
that runs from a web client through an API gateway into two backend services means the same four
repositories are picked, one chip at a time, every time a task is created. The selection is
knowledge the user already has and re-enters by hand on every task, and a repository forgotten on
the third of four chips is only discovered once the agent cannot find the code.
**User story:** As a user, I want to save each repository base branch, so that I
do not select every branch for each task.
A **repository set** is a named, reusable group of workspace repositories. Choosing a set fills the
task-creation repository picker with every repository in it, in one action. Branches remain a
per-task decision and are never stored in a set.
#### Acceptance criteria
## What
- **AC-WORKSPACES-REPOSITORY-SETS-002.1:** Each set member shall store zero or
one saved base. A member without a saved base shall use normal task-form
defaulting when the set is applied.
- **AC-WORKSPACES-REPOSITORY-SETS-002.2:** The set editor shall show selected
members as searchable, ordered rows. Each row shall show its repository and a
base-branch selector.
- **AC-WORKSPACES-REPOSITORY-SETS-002.3:** The selector shall include a
`Task default` choice. The choice shall show the current repository default
as supporting context when that value is available.
- **AC-WORKSPACES-REPOSITORY-SETS-002.4:** A reset action shall remove all saved
bases from the draft. The action shall not remove or reorder members.
- **AC-WORKSPACES-REPOSITORY-SETS-002.5:** Branch lists shall load when a user
opens a selector. Opening a set with many members shall not start one branch
request for every member.
- **AC-WORKSPACES-REPOSITORY-SETS-002.6:** A saved base that is no longer
available shall remain visible with an unavailable state. The system shall
not replace it with another branch.
- **AC-WORKSPACES-REPOSITORY-SETS-002.7:** The desktop editor shall use a modal
surface. The phone editor shall use a full-height surface with one scroll
region and a safe-area action footer.
- **AC-WORKSPACES-REPOSITORY-SETS-002.8:** All required actions shall support
keyboard, pointer, and touch input. Touch targets shall be at least 44 CSS
pixels in their active dimension.
- **AC-WORKSPACES-REPOSITORY-SETS-002.9:** The base control shall use the same
selector behavior as New Task. It shall include branch search, refresh,
remote-qualified names, and local or remote-origin labels.
### Defining a set
### REQ-WORKSPACES-REPOSITORY-SETS-003: Task form application
- A workspace owns any number of repository sets. A set has a name, an optional description, and an
ordered list of repositories drawn from that same workspace.
- Sets are managed from **Settings > Workspaces > _workspace_ > Repositories**, in a **Repository
sets** section below the repository list. The section lists each set with its name, description,
and member repositories, and offers create, rename, edit-members, reorder, and delete.
- Sets can also be created from the task-creation dialog: **Save as set** captures the workspace
repositories currently selected in the form, asks for a name, and creates the set without
discarding the in-progress task.
- A set name is trimmed, is 1 to 100 characters, and is unique within its workspace, compared
case-insensitively. A second set named `Full-stack` in a workspace that already has `full-stack`
is rejected and the existing set is named in the error.
- A set holds each repository at most once. Ordering is user-controlled and is the order in which
the set fills the picker.
- Creating or updating a set requires at least one repository. A set that becomes empty because its
repositories were deleted is kept, not removed.
- Sets are workspace-scoped, not per-user: every user who can see the workspace sees and can apply
its sets.
**Intent:** Applying a set fills the task form without removing user work.
### Applying a set
#### Acceptance criteria
- The repository row of the task-creation dialog gains a **Sets** control next to **add
repository**, listing the workspace's sets by name with their repository count.
- Choosing a set adds one repository row per member, in set order, leaving each row's branch empty
so the dialog's existing per-row branch defaulting fills it. The user then reviews and adjusts
branches exactly as when adding rows by hand.
- A single empty placeholder row is consumed by the first applied repository rather than left
behind. Rows the user already configured are never discarded or reordered by applying a set.
- Applying a set is additive and idempotent: a repository already present in the form is skipped,
so applying the same set twice changes nothing and applying two overlapping sets yields the union.
- A member repository that no longer exists in the workspace, or was deleted, is skipped. When any
member is skipped the dialog reports how many were skipped and why, as visible text.
- The same control appears in the **new subtask** form, which uses the same repository picker. It
does not appear in Quick Chat.
- The control is absent when the workspace has no sets **and** the current selection cannot be saved
as one, so a control that could only report "you have no sets" never appears. When the form does
hold a workspace repository, the control stays available with **Save as set** and no set entries:
otherwise the first set could never be defined from the flow that just chose the repositories.
- The control is absent in **Remote URL** and **No repository** source modes, where workspace
repositories are not what is being selected.
- The control is never disabled by executor capability. **Add repository** beside it is not
executor-gated either: the repository selection is what constrains which executor profiles stay
selectable, and the executor picker already marks an incompatible profile unavailable with its
reason. Applying a set on a single-repository executor therefore behaves exactly like adding the
same rows by hand.
- Applying a set changes only the form. No task, repository, or set is modified, and nothing is
persisted until the task is created.
- **AC-WORKSPACES-REPOSITORY-SETS-003.1:** New Task and New Subtask shall show a
`Sets` control next to the repository action. Each option shall show the set
name and member count.
- **AC-WORKSPACES-REPOSITORY-SETS-003.2:** Applying a set shall add one row for
each available member, in set order. The action shall consume one empty
placeholder row.
- **AC-WORKSPACES-REPOSITORY-SETS-003.3:** Applying a set shall skip a repository
that is already in the form. The action shall not change or reorder that
existing row.
- **AC-WORKSPACES-REPOSITORY-SETS-003.4:** Applying a set shall copy each saved
base into its new task row. A member without a saved base shall use normal
task-form defaulting.
- **AC-WORKSPACES-REPOSITORY-SETS-003.5:** The task form shall show an unavailable
copied base. The user shall select an available base or the task default
before task creation.
- **AC-WORKSPACES-REPOSITORY-SETS-003.6:** Local execution shall keep the saved
base separate from the checkout branch. Applying a set shall not silently use
the saved base as the local checkout branch.
- **AC-WORKSPACES-REPOSITORY-SETS-003.7:** Applying a set shall change only the
task draft. Later set changes shall not change existing tasks or open drafts.
- **AC-WORKSPACES-REPOSITORY-SETS-003.8:** When members are missing, the form
shall skip them and show the number skipped. Applying the same set twice
shall not add duplicate rows.
- **AC-WORKSPACES-REPOSITORY-SETS-003.9:** `Save as set` shall capture the
workspace repositories and effective bases shown in the task form. The dialog
shall state that it saves base choices. A `Task default` choice shall remain
an empty saved base.
When a repository appears on several rows, saving shall keep the first row and
its effective base. The dialog shall explain this rule and report additional
rows separately from rows that are not workspace repositories.
- **AC-WORKSPACES-REPOSITORY-SETS-003.10:** The control shall remain absent from
Quick Chat, Remote URL, and No repository modes. Executor capability shall not
hide or disable the control.
### Live updates
## Compatibility
- A set created, changed, or deleted in one client appears in every other client viewing that
workspace without a reload.
## Data model
Two new tables, both workspace-owned.
`repository_sets`
| Field | Contract |
| -------------------------- | ----------------------------------------------------------------- |
| `id` | Stable set identity. |
| `workspace_id` | Owning workspace; cascade-deleted with the workspace. |
| `name` | Trimmed, 1 to 100 characters, unique per workspace. |
| `description` | Optional free text, defaults to empty. |
| `created_at`, `updated_at` | Audit timestamps. |
`(workspace_id, name)` is unique in the database, and a second unique index on
`(workspace_id, LOWER(name))` makes that backstop case-insensitive too. The service rejects a
case-insensitive collision with a message naming the existing set; the index is what stops two
concurrent creates of `Full-stack` and `full-stack` from both landing.
`repository_set_items`
| Field | Contract |
| -------------------------- | ------------------------------------------------------------------- |
| `id` | Stable membership identity. |
| `repository_set_id` | Owning set; cascade-deleted with the set. |
| `repository_id` | A repository in the same workspace as the set. |
| `position` | Zero-based order within the set; contiguous after every write. |
| `created_at`, `updated_at` | Audit timestamps. |
`(repository_set_id, repository_id)` is unique: a set cannot list the same repository twice.
Sets deliberately store **no branch**. Branch choice belongs to a task, is already modelled on
`task_repositories.base_branch` / `checkout_branch`, and is what the user still decides per task.
Repositories are soft-deleted (`repositories.deleted_at`), so a foreign-key cascade does not remove
memberships. Deleting a repository removes its `repository_set_items` rows in the same transaction
that soft-deletes it, and every read of a set also excludes members whose repository is soft-deleted
or has moved out of the workspace. Either mechanism alone is sufficient; both are present so a set
can never surface a repository the user cannot select.
## API surface
Collection routes are workspace-scoped, item routes are flat, matching the existing repository
routes.
```
GET /api/v1/workspaces/:id/repository-sets
POST /api/v1/workspaces/:id/repository-sets
GET /api/v1/repository-sets/:id
PATCH /api/v1/repository-sets/:id
DELETE /api/v1/repository-sets/:id
```
Create request:
```json
{
"name": "Full-stack",
"description": "web + gateway + services",
"repository_ids": ["repo-web", "repo-gateway", "repo-orders"]
}
```
`repository_ids` is ordered and defines `position`. Update accepts `name`, `description`, and
`repository_ids` independently; a supplied `repository_ids` replaces the whole membership list,
which is also how reordering is expressed.
Response:
```json
{
"id": "set-1",
"workspace_id": "ws-1",
"name": "Full-stack",
"description": "web + gateway + services",
"repositories": [
{ "repository_id": "repo-web", "position": 0 },
{ "repository_id": "repo-gateway", "position": 1 },
{ "repository_id": "repo-orders", "position": 2 }
],
"created_at": "2026-08-17T09:00:00Z",
"updated_at": "2026-08-17T09:00:00Z"
}
```
The same five operations are available over the WebSocket dispatcher as
`repository_set.list|create|get|update|delete`, mirroring `repository.*`, so clients on the socket
do not need a second transport.
Set membership is also exposed on the boot payload as a workspace-keyed `repositorySets`
collection, so the task-creation dialog can offer sets on first paint without a fetch.
## Permissions
- Every route resolves the workspace and applies the same workspace-access authorization already
required for repository routes. A user without access to the workspace receives the same response
as for a workspace that does not exist.
- Item routes (`/repository-sets/:id`) resolve the set's workspace first and authorize against it;
a set id from another workspace is not readable, writable, or deletable.
- `repository_ids` are validated against the set's workspace. A repository id from another
workspace is rejected and does not reveal whether that id exists.
## Failure modes
| Situation | Result |
| ------------------------------------------------------------ | --------------------------------------------------------------- |
| Malformed body, blank name, name over 100 characters | `400`, no write |
| Empty or absent `repository_ids` on create, empty on update | `400`, no write |
| Duplicate id inside `repository_ids` | `400`, no write |
| Unknown workspace, unknown set, or no workspace access | `404`, no write |
| Name already used in the workspace, case-insensitively | `409` naming the existing set, no write |
| A `repository_id` is unknown, deleted, or in another workspace | `422` listing the offending ids, no write |
| Repository listing fails while building the boot payload | Boot succeeds with an empty set list; the dialog fetches instead |
| A set's members were all deleted | The set lists as empty and cannot be applied; it is not removed |
| Applying a set on an executor that forbids multi-repository | The set applies; the executor picker marks that profile unavailable |
Writes are atomic: name, description, and membership of one request either all land or none do,
because the store applies both halves in a single transaction. A set deleted between the read and
the write is reported as not found rather than resurrected.
## Persistence guarantees
- A created or updated set is durable before the response returns, and its membership positions are
contiguous from zero in the order requested.
- Deleting a workspace deletes its sets and memberships. Deleting a set deletes its memberships and
leaves every repository untouched. Deleting a repository removes it from every set, leaves the
sets themselves intact, and publishes each affected set's new shape so open clients stop offering
the deleted member.
- Applying a set writes nothing. The repositories that reach `task_repositories` are whatever the
form holds at submit, so a set applied and then edited persists the edited selection.
- Sets survive backup and restore with the rest of the workspace schema, and the schema
initialization is replay-safe: initializing twice against the same database is a no-op.
- Existing sets shall load with no saved bases and shall preserve current task
defaulting.
- Existing create and update clients that send ordered `repository_ids` shall
remain valid. Those members shall have no saved bases.
- Deleting a set shall not change its repositories or tasks that used it.
## Out of scope
- Branches, agent profiles, executor profiles, or workflows stored in a set. A set is repositories.
- Remote-URL and folder sources. Sets reference workspace repository entities.
- Quick Chat, and adding sources to an already-created task.
- Cross-workspace and per-user sets.
- Automatic set suggestions derived from task history.
- A shared branch value that is applied to every repository.
- Branch policies, branch templates, pull-request targets, agent profiles,
executor profiles, or workflows in a set.
- Remote-URL sources, folder sources, cross-workspace sets, and per-user sets.
- Automatic repair of unavailable saved bases.
## Traceability
- System design: [Repository sets](../system-design/repository-sets.md)
- Implementation plan: [Repository set base branches](../../../plans/repository-set-base-branches/plan.md)
@@ -0,0 +1,281 @@
---
status: draft
system: workspaces
requirements:
- REQ-WORKSPACES-REPOSITORY-SETS-001
- REQ-WORKSPACES-REPOSITORY-SETS-002
- REQ-WORKSPACES-REPOSITORY-SETS-003
---
# Repository Sets System Design
## Purpose and boundaries
The workspace system owns repository-set persistence, authorization, transport,
and settings behavior. The task form consumes a set as a reusable draft input.
A set stores an optional base for each member. Applying a set copies this value
into the task draft. The task service remains the authority for task-repository
snapshots and launch behavior.
The saved base does not change `repositories.default_branch`. It also does not
create a live link between a set and a task.
## Requirement mapping
| Requirement | Design sections |
| ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `REQ-WORKSPACES-REPOSITORY-SETS-001` | [Persistence](#persistence), [Service and transport](#service-and-transport), [Events and boot state](#events-and-boot-state) |
| `REQ-WORKSPACES-REPOSITORY-SETS-002` | [Settings experience](#settings-experience), [Shared branch selector](#shared-branch-selector), [Branch loading](#branch-loading), [Responsive behavior](#responsive-behavior) |
| `REQ-WORKSPACES-REPOSITORY-SETS-003` | [Task form application](#task-form-application), [Local executor semantics](#local-executor-semantics), [Failure and recovery](#failure-and-recovery) |
## Data model
`repository_sets` keeps its current fields and workspace ownership.
`repository_set_items` adds `base_branch TEXT NOT NULL DEFAULT ''`. An empty
value means that the set does not override normal task-form defaulting.
The `RepositorySetItem` model and DTO add `BaseBranch` and `base_branch`.
Responses always include the ordered member list. A member can omit
`base_branch` when it has no override.
The set remains a draft template. It does not store a branch-policy ID,
checkout branch, generated task branch, or pull-request target.
## Persistence
The SQLite and PostgreSQL schema migration adds the new column before any query
uses it. The migration is replay-safe. The base schema includes the column for
new databases.
Repository-set create and update operations write metadata and all member
fields in one transaction. Membership replacement preserves the requested
order and assigns contiguous positions.
Existing rows receive an empty base. Backup and restore include the new column
through the existing database process.
## Service and transport
The service accepts ordered member inputs with `repository_id` and optional
`base_branch`. It validates these facts before the transaction:
- Each repository is live and belongs to the set workspace.
- Each repository occurs once.
- Each non-empty base passes `securityutil.IsValidBaseBranchRef`.
The service does not query Git during a write. Branch existence can change
after any check, and Git access can require credentials that are not available
to a settings request.
Create and update payloads add an ordered `repositories` field:
```json
{
"name": "Central",
"description": "Core services",
"repositories": [
{ "repository_id": "repo-document", "base_branch": "develop" },
{ "repository_id": "repo-classifier", "base_branch": "" }
]
}
```
The existing `repository_ids` field remains a compatibility input. Each ID
becomes a member with an empty base. A request that contains both member fields
is invalid because their order or values can conflict.
HTTP and WebSocket handlers use the same request type and service methods. The
same error categories remain stable. An unsafe base produces a validation
error and no write.
## Events and boot state
Repository-set DTOs include `base_branch` in list, get, create, and update
responses. The `repository_set.created` and `repository_set.updated` events use
the same member shape.
The boot-state projection uses the DTO without a second mapping. The workspace
store and WebSocket handlers preserve the complete member objects.
## Settings experience
The approved editor uses one inline row for each selected repository. The
desktop modal contains these elements:
- Name and description fields.
- A search field for the selected-member list.
- Ordered member rows with a repository label and base selector.
- An Add repository action.
- A Reset bases action.
- Cancel and Save actions.
The base selector starts with `Task default`. When available, supporting text
also shows `repositories.default_branch`. Choosing this option stores an empty
base and preserves current task-form defaulting.
The list shows only selected members. The Add repository action opens a
searchable list of remaining workspace repositories. Existing move controls
preserve explicit member order.
Set summary cards keep their current member chips. A member with a saved base
shows the base as secondary text or a compact suffix. The card does not fetch
branch lists.
## Shared branch selector
The set editor reuses the `Pill` popover/list primitive from
`task-create-dialog-pill.tsx`, the same primitive rendered by New Task's
`RepoChipBranchPill`. It does not create a settings-only branch selector or a
second lookalike popover.
The editor builds branch options with `sortBranches`, `branchOptionValue`, and
`branchToOption` from `task-create-dialog-branch-options.tsx`. This shared
option model supplies these behaviors:
- Search uses the same branch scoring and keywords as New Task.
- Remote branches keep qualified names such as `origin/main`.
- Local branches show the `local` badge.
- Remote branches show the remote name, such as `origin` or `followup`.
- Provider branches without a remote name show the `remote` badge.
- The shared popover keeps search and refresh in one row, groups branches under
`Branches`, and preserves the New Task keyboard, pointer, and portal behavior.
The editor supplies its dialog or drawer surface as the shared picker portal
container. Pickers remain inside the modal scroll-lock boundary but outside
the scrolling form. The outer surface allows overflow so it does not clip
the floating picker. Mouse-wheel and touch gestures scroll the option list.
`Task default` is a synthetic first option for base pickers. It does not change
the shared mapping, search terms, ordering, badges, or selected-branch display.
The shared `Pill` accepts an optional open-state callback for settings' lazy
branch loading. New Task keeps its current behavior when the callback is
absent.
## Branch loading
The trigger can show these values without a branch request:
- A saved base from the set member.
- The repository default from the workspace repository DTO.
- The loading or unavailable state.
The editor enables `useBranches` only while a row selector is open. This rule
prevents an immediate request for every member in a large set.
After the list loads, the selector compares the saved base with all available
branch option values from `branchOptionValue`. A missing value remains in the
selector as a disabled fallback option with an unavailable marker.
The frontend can save an unchanged unavailable value. A user cannot select a
new value that is absent from the current list. The backend still validates the
Git-ref syntax for all values.
## Responsive behavior
Desktop uses the existing dialog pattern. The dialog has a bounded height and
one internal scroll region for member rows.
Phone layouts use the full-height drawer pattern from repository branch-policy
settings. The drawer uses `100dvh`, one scrolling body, and a fixed footer. The
footer includes safe-area padding.
Each member row stacks its repository label above a full-width base selector on
phones. Add, remove, move, reset, save, and cancel actions have touch targets of
at least 44 CSS pixels.
The phone editor uses the same searchable `Pill` popover as New Task. The
popover stays inside the viewport and owns the temporary option-list scroll. It
keeps the origin badges, refresh action, focus return, and keyboard behavior.
The domain draft, validation, mutations, and branch option mapping are shared
between desktop and phone layouts. Only the surface composition changes.
## Task form application
`RepositorySetItem` supplies the saved base to `applyRepositorySet`. A new
`TaskRepoRow` keeps the copied base separate from checkout state.
Applying a set follows this order:
1. Resolve the member against the current workspace repository list.
2. Skip the member when the repository is missing or already in the form.
3. Add a row with the saved base and no branch-policy selection.
4. Let normal row defaulting resolve an empty saved base.
The action never changes an existing row. This rule preserves manual edits and
keeps repeated or overlapping set application idempotent.
The task branch selector shows the effective base. If a copied saved base is
unavailable, the row shows the value and blocks submission. The user must select
an available branch or `Task default`. Selecting `Task default` clears the
copied base so normal task-form branch autoselection runs again.
`Save as set` sends ordered member objects. It captures each workspace
repository and the effective base in the draft. A `Task default` choice sends
an empty base. The visible summary states that base choices are included.
Saving keeps the first row for each workspace repository, including its effective
base. The dialog counts additional rows separately from non-workspace rows and
explains that only the first row is saved. Neither exclusion changes the draft.
## Local executor semantics
The task draft keeps these values separate:
- The effective base from the set member or repository default.
- The checkout branch that represents the current local working branch.
For worktree-based execution, the effective base remains the source for
`base_branch`. For local execution, submission sends the effective base as
`base_branch` and the local working branch as `checkout_branch` when they
differ.
The task row shows both values when local execution makes them different. A
copied saved base never changes the local checkout branch as a side effect.
## Failure and recovery
| Condition | Behavior |
| -------------------------------------------- | ----------------------------------------------------------------- |
| A member repository is missing | Applying skips the member and reports the count. |
| A saved base is unsafe | The service rejects the set write with no partial change. |
| A saved base disappears | Settings and task creation keep the value visible as unavailable. |
| A branch request fails | The open selector shows retry behavior and keeps the draft value. |
| An old client sends `repository_ids` | The service stores members with no saved bases. |
| An open task draft already contains a member | Applying keeps the existing row unchanged. |
The frontend does not substitute a repository default for an unavailable saved
base. This rule prevents tasks from starting from an unintended branch.
## Permissions and security
Repository-set routes keep their current workspace authorization. Item routes
resolve the owning workspace before read or write access.
The service validates all repository IDs against the set workspace. It does not
reveal whether a cross-workspace ID exists. Safe Git-ref validation prevents a
saved base from becoming a command argument with unsafe syntax.
## Localization and accessibility
All new labels, hints, unavailable states, errors, and summaries use i18n keys.
The five supported catalogs contain the same keys and placeholders.
The editor uses semantic controls and visible labels. Selector triggers expose
accessible names. Focus returns to the trigger after a selector closes.
## Verification strategy
- Repository tests cover fresh schema, replay, existing-row migration, ordered
base persistence, atomic replacement, and SQLite/PostgreSQL behavior.
- Service and handler tests cover validation, compatibility payloads, DTOs,
events, and authorization.
- Frontend unit tests cover API mapping, store events, apply rules, unavailable
bases, save-as-set input, and local base-versus-checkout submission.
- Component tests cover search, add, remove, reorder, reset, lazy branch loads,
and responsive surface selection.
- Desktop and mobile Playwright tests create a set, save member bases, apply the
set, and create a task with the expected repository bases.