feat(cli): manage workspace repo registry (#4067)

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
Bohan Jiang
2026-06-13 01:22:45 +08:00
committed by GitHub
parent 7d28b5a040
commit 7db3e507d1
4 changed files with 627 additions and 1 deletions

View File

@@ -2,13 +2,16 @@ package main
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"strings"
"time"
"github.com/multica-ai/multica/server/internal/cli"
"github.com/spf13/cobra"
)
@@ -17,6 +20,32 @@ var repoCmd = &cobra.Command{
Short: "Work with repositories",
}
var repoListCmd = &cobra.Command{
Use: "list",
Short: "List workspace repositories",
Long: "Lists the repository registry for the current workspace. These are workspace-level repos, separate from project resources.",
Args: cobra.NoArgs,
RunE: runRepoList,
}
var repoAddCmd = &cobra.Command{
Use: "add [url]...",
Short: "Add repositories to the workspace registry",
Long: "Adds one or more repository URLs to the current workspace repository registry. " +
"Existing URLs are not duplicated. Use project resources when you need project-specific context instead.",
Args: cobra.ArbitraryArgs,
RunE: runRepoAdd,
}
var repoRemoveCmd = &cobra.Command{
Use: "remove [url]...",
Aliases: []string{"rm"},
Short: "Remove repositories from the workspace registry",
Long: "Removes one or more repository URLs from the current workspace repository registry.",
Args: cobra.ArbitraryArgs,
RunE: runRepoRemove,
}
var repoCheckoutCmd = &cobra.Command{
Use: "checkout <url>",
Short: "Check out a repository into the working directory",
@@ -28,10 +57,280 @@ var repoCheckoutCmd = &cobra.Command{
var repoCheckoutRef string
func init() {
repoListCmd.Flags().String("output", "table", "Output format: table or json")
repoAddCmd.Flags().StringArray("url", nil, "Repository URL to add (may be repeated)")
repoAddCmd.Flags().String("description", "", "Optional description; only valid when adding one URL")
repoAddCmd.Flags().String("output", "json", "Output format: table or json")
repoRemoveCmd.Flags().StringArray("url", nil, "Repository URL to remove (may be repeated)")
repoRemoveCmd.Flags().String("output", "json", "Output format: table or json")
repoCheckoutCmd.Flags().StringVar(&repoCheckoutRef, "ref", "", "branch, tag, or commit to check out instead of the remote default branch")
repoCmd.AddCommand(repoListCmd)
repoCmd.AddCommand(repoAddCmd)
repoCmd.AddCommand(repoRemoveCmd)
repoCmd.AddCommand(repoCheckoutCmd)
}
type workspaceRepo struct {
URL string `json:"url"`
Description string `json:"description,omitempty"`
}
type repoWorkspaceResponse struct {
ID string `json:"id"`
Name string `json:"name"`
Slug string `json:"slug"`
Repos []workspaceRepo `json:"repos"`
}
type repoMutationResult struct {
WorkspaceID string `json:"workspace_id"`
Added []workspaceRepo `json:"added,omitempty"`
Updated []workspaceRepo `json:"updated,omitempty"`
Removed []workspaceRepo `json:"removed,omitempty"`
Repos []workspaceRepo `json:"repos"`
}
func repoURLsFromArgsAndFlags(cmd *cobra.Command, args []string) ([]string, error) {
flagURLs, _ := cmd.Flags().GetStringArray("url")
raw := append([]string{}, flagURLs...)
raw = append(raw, args...)
if len(raw) == 0 {
return nil, fmt.Errorf("at least one repository URL is required")
}
urls := make([]string, 0, len(raw))
seen := make(map[string]struct{}, len(raw))
for _, u := range raw {
u = strings.TrimSpace(u)
if u == "" {
return nil, fmt.Errorf("repository URL cannot be empty")
}
if _, ok := seen[u]; ok {
continue
}
seen[u] = struct{}{}
urls = append(urls, u)
}
return urls, nil
}
func fetchRepoWorkspace(ctx context.Context, client *cli.APIClient, workspaceID string) (repoWorkspaceResponse, error) {
var ws repoWorkspaceResponse
if err := client.GetJSON(ctx, "/api/workspaces/"+workspaceID, &ws); err != nil {
return repoWorkspaceResponse{}, fmt.Errorf("get workspace: %w", err)
}
if ws.Repos == nil {
ws.Repos = []workspaceRepo{}
}
return ws, nil
}
func patchWorkspaceRepos(ctx context.Context, client *cli.APIClient, workspaceID string, repos []workspaceRepo) (repoWorkspaceResponse, error) {
var ws repoWorkspaceResponse
if err := client.PatchJSON(ctx, "/api/workspaces/"+workspaceID, map[string]any{"repos": repos}, &ws); err != nil {
return repoWorkspaceResponse{}, fmt.Errorf("update workspace repos: %w", err)
}
if ws.Repos == nil {
ws.Repos = []workspaceRepo{}
}
return ws, nil
}
func repoCommandClient(cmd *cobra.Command) (*cli.APIClient, string, error) {
workspaceID, err := requireWorkspaceID(cmd)
if err != nil {
return nil, "", err
}
client, err := newAPIClient(cmd)
if err != nil {
return nil, "", err
}
return client, workspaceID, nil
}
func runRepoList(cmd *cobra.Command, _ []string) error {
client, workspaceID, err := repoCommandClient(cmd)
if err != nil {
return err
}
ctx, cancel := cli.APIContext(context.Background())
defer cancel()
ws, err := fetchRepoWorkspace(ctx, client, workspaceID)
if err != nil {
return err
}
output, _ := cmd.Flags().GetString("output")
if output == "json" {
return cli.PrintJSON(os.Stdout, ws.Repos)
}
if len(ws.Repos) == 0 {
fmt.Fprintln(os.Stderr, "No repositories found.")
return nil
}
rows := make([][]string, 0, len(ws.Repos))
for _, repo := range ws.Repos {
rows = append(rows, []string{repo.URL, repo.Description})
}
cli.PrintTable(os.Stdout, []string{"URL", "DESCRIPTION"}, rows)
return nil
}
func runRepoAdd(cmd *cobra.Command, args []string) error {
urls, err := repoURLsFromArgsAndFlags(cmd, args)
if err != nil {
return err
}
description, _ := cmd.Flags().GetString("description")
descriptionChanged := cmd.Flags().Changed("description")
if descriptionChanged && len(urls) > 1 {
return fmt.Errorf("--description can only be used when adding one repository URL")
}
client, workspaceID, err := repoCommandClient(cmd)
if err != nil {
return err
}
ctx, cancel := cli.APIContext(context.Background())
defer cancel()
ws, err := fetchRepoWorkspace(ctx, client, workspaceID)
if err != nil {
return err
}
indexByURL := make(map[string]int, len(ws.Repos))
for i, repo := range ws.Repos {
indexByURL[repo.URL] = i
}
added := []workspaceRepo{}
updated := []workspaceRepo{}
repos := append([]workspaceRepo{}, ws.Repos...)
for _, u := range urls {
if idx, ok := indexByURL[u]; ok {
if descriptionChanged && repos[idx].Description != description {
repos[idx].Description = description
updated = append(updated, repos[idx])
}
continue
}
repo := workspaceRepo{URL: u}
if descriptionChanged {
repo.Description = description
}
indexByURL[u] = len(repos)
repos = append(repos, repo)
added = append(added, repo)
}
if len(added) > 0 || len(updated) > 0 {
ws, err = patchWorkspaceRepos(ctx, client, workspaceID, repos)
if err != nil {
return err
}
} else {
ws.Repos = repos
}
result := repoMutationResult{
WorkspaceID: ws.ID,
Added: added,
Updated: updated,
Repos: ws.Repos,
}
output, _ := cmd.Flags().GetString("output")
if output == "json" {
return cli.PrintJSON(os.Stdout, result)
}
if len(added) == 0 && len(updated) == 0 {
fmt.Fprintln(os.Stdout, "No repository changes.")
return nil
}
rows := make([][]string, 0, len(added)+len(updated))
for _, repo := range added {
rows = append(rows, []string{"added", repo.URL, repo.Description})
}
for _, repo := range updated {
rows = append(rows, []string{"updated", repo.URL, repo.Description})
}
cli.PrintTable(os.Stdout, []string{"ACTION", "URL", "DESCRIPTION"}, rows)
return nil
}
func runRepoRemove(cmd *cobra.Command, args []string) error {
urls, err := repoURLsFromArgsAndFlags(cmd, args)
if err != nil {
return err
}
client, workspaceID, err := repoCommandClient(cmd)
if err != nil {
return err
}
ctx, cancel := cli.APIContext(context.Background())
defer cancel()
ws, err := fetchRepoWorkspace(ctx, client, workspaceID)
if err != nil {
return err
}
removeSet := make(map[string]struct{}, len(urls))
for _, u := range urls {
removeSet[u] = struct{}{}
}
removedSet := make(map[string]struct{}, len(urls))
removed := []workspaceRepo{}
repos := make([]workspaceRepo, 0, len(ws.Repos))
for _, repo := range ws.Repos {
if _, ok := removeSet[repo.URL]; ok {
removed = append(removed, repo)
removedSet[repo.URL] = struct{}{}
continue
}
repos = append(repos, repo)
}
missing := []string{}
for _, u := range urls {
if _, ok := removedSet[u]; !ok {
missing = append(missing, u)
}
}
if len(missing) > 0 {
return fmt.Errorf("repository not found in workspace registry: %s", strings.Join(missing, ", "))
}
ws, err = patchWorkspaceRepos(ctx, client, workspaceID, repos)
if err != nil {
return err
}
result := repoMutationResult{
WorkspaceID: ws.ID,
Removed: removed,
Repos: ws.Repos,
}
output, _ := cmd.Flags().GetString("output")
if output == "json" {
return cli.PrintJSON(os.Stdout, result)
}
rows := make([][]string, 0, len(removed))
for _, repo := range removed {
rows = append(rows, []string{repo.URL, repo.Description})
}
cli.PrintTable(os.Stdout, []string{"REMOVED URL", "DESCRIPTION"}, rows)
return nil
}
func runRepoCheckout(cmd *cobra.Command, args []string) error {
repoURL := args[0]

View File

@@ -0,0 +1,190 @@
package main
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/spf13/cobra"
)
func newRepoRegistryTestCmd(serverURL string) *cobra.Command {
cmd := &cobra.Command{Use: "repo-test"}
cmd.Flags().String("server-url", "", "")
cmd.Flags().String("workspace-id", "", "")
cmd.Flags().String("profile", "", "")
cmd.Flags().StringArray("url", nil, "")
cmd.Flags().String("description", "", "")
cmd.Flags().String("output", "json", "")
_ = cmd.Flags().Set("server-url", serverURL)
_ = cmd.Flags().Set("workspace-id", "ws-1")
return cmd
}
func TestRunRepoAddAppendsAndDedupes(t *testing.T) {
initialRepos := []workspaceRepo{{URL: "https://git.example.com/web.git"}}
var patched []workspaceRepo
patchCount := 0
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == http.MethodGet && r.URL.Path == "/api/workspaces/ws-1":
json.NewEncoder(w).Encode(repoWorkspaceResponse{ID: "ws-1", Repos: initialRepos})
case r.Method == http.MethodPatch && r.URL.Path == "/api/workspaces/ws-1":
patchCount++
var body struct {
Repos []workspaceRepo `json:"repos"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Fatalf("decode patch body: %v", err)
}
patched = body.Repos
json.NewEncoder(w).Encode(repoWorkspaceResponse{ID: "ws-1", Repos: body.Repos})
default:
http.NotFound(w, r)
}
}))
defer srv.Close()
cmd := newRepoRegistryTestCmd(srv.URL)
if err := cmd.Flags().Set("url", "https://git.example.com/web.git"); err != nil {
t.Fatal(err)
}
err := runRepoAdd(cmd, []string{
"https://git.example.com/api.git",
"https://git.example.com/api.git",
})
if err != nil {
t.Fatalf("runRepoAdd: %v", err)
}
if patchCount != 1 {
t.Fatalf("patchCount = %d, want 1", patchCount)
}
if len(patched) != 2 {
t.Fatalf("patched repos = %+v, want 2 entries", patched)
}
if patched[0].URL != "https://git.example.com/web.git" || patched[1].URL != "https://git.example.com/api.git" {
t.Fatalf("unexpected patched repos: %+v", patched)
}
}
func TestRunRepoAddUpdatesDescriptionForExistingRepo(t *testing.T) {
initialRepos := []workspaceRepo{{URL: "https://git.example.com/web.git", Description: "old"}}
var patched []workspaceRepo
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == http.MethodGet && r.URL.Path == "/api/workspaces/ws-1":
json.NewEncoder(w).Encode(repoWorkspaceResponse{ID: "ws-1", Repos: initialRepos})
case r.Method == http.MethodPatch && r.URL.Path == "/api/workspaces/ws-1":
var body struct {
Repos []workspaceRepo `json:"repos"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Fatalf("decode patch body: %v", err)
}
patched = body.Repos
json.NewEncoder(w).Encode(repoWorkspaceResponse{ID: "ws-1", Repos: body.Repos})
default:
http.NotFound(w, r)
}
}))
defer srv.Close()
cmd := newRepoRegistryTestCmd(srv.URL)
if err := cmd.Flags().Set("description", "new"); err != nil {
t.Fatal(err)
}
if err := runRepoAdd(cmd, []string{"https://git.example.com/web.git"}); err != nil {
t.Fatalf("runRepoAdd: %v", err)
}
if len(patched) != 1 || patched[0].Description != "new" {
t.Fatalf("patched repos = %+v, want updated description", patched)
}
}
func TestRunRepoAddRejectsDescriptionForMultipleRepos(t *testing.T) {
cmd := newRepoRegistryTestCmd("http://127.0.0.1:0")
if err := cmd.Flags().Set("description", "shared"); err != nil {
t.Fatal(err)
}
err := runRepoAdd(cmd, []string{"https://git.example.com/a.git", "https://git.example.com/b.git"})
if err == nil {
t.Fatal("expected error")
}
if !strings.Contains(err.Error(), "--description") {
t.Fatalf("error = %q, want description guidance", err)
}
}
func TestRunRepoRemoveDeletesExistingRepos(t *testing.T) {
initialRepos := []workspaceRepo{
{URL: "https://git.example.com/web.git"},
{URL: "https://git.example.com/api.git"},
{URL: "https://git.example.com/mobile.git"},
}
var patched []workspaceRepo
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == http.MethodGet && r.URL.Path == "/api/workspaces/ws-1":
json.NewEncoder(w).Encode(repoWorkspaceResponse{ID: "ws-1", Repos: initialRepos})
case r.Method == http.MethodPatch && r.URL.Path == "/api/workspaces/ws-1":
var body struct {
Repos []workspaceRepo `json:"repos"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Fatalf("decode patch body: %v", err)
}
patched = body.Repos
json.NewEncoder(w).Encode(repoWorkspaceResponse{ID: "ws-1", Repos: body.Repos})
default:
http.NotFound(w, r)
}
}))
defer srv.Close()
cmd := newRepoRegistryTestCmd(srv.URL)
if err := cmd.Flags().Set("url", "https://git.example.com/mobile.git"); err != nil {
t.Fatal(err)
}
if err := runRepoRemove(cmd, []string{"https://git.example.com/web.git"}); err != nil {
t.Fatalf("runRepoRemove: %v", err)
}
if len(patched) != 1 || patched[0].URL != "https://git.example.com/api.git" {
t.Fatalf("patched repos = %+v, want only api repo", patched)
}
}
func TestRunRepoRemoveRejectsMissingRepoWithoutPatch(t *testing.T) {
patchCount := 0
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == http.MethodGet && r.URL.Path == "/api/workspaces/ws-1":
json.NewEncoder(w).Encode(repoWorkspaceResponse{
ID: "ws-1",
Repos: []workspaceRepo{{URL: "https://git.example.com/web.git"}},
})
case r.Method == http.MethodPatch && r.URL.Path == "/api/workspaces/ws-1":
patchCount++
json.NewEncoder(w).Encode(repoWorkspaceResponse{ID: "ws-1"})
default:
http.NotFound(w, r)
}
}))
defer srv.Close()
cmd := newRepoRegistryTestCmd(srv.URL)
err := runRepoRemove(cmd, []string{"https://git.example.com/missing.git"})
if err == nil {
t.Fatal("expected error")
}
if !strings.Contains(err.Error(), "not found") {
t.Fatalf("error = %q, want not found", err)
}
if patchCount != 0 {
t.Fatalf("patchCount = %d, want 0", patchCount)
}
}

View File

@@ -2,6 +2,7 @@ package handler
import (
"encoding/json"
"fmt"
"log/slog"
"net/http"
"regexp"
@@ -249,6 +250,47 @@ type UpdateWorkspaceRequest struct {
AvatarURL *string `json:"avatar_url"`
}
type workspaceRepoRef struct {
URL string `json:"url"`
Description string `json:"description,omitempty"`
}
func validateAndNormalizeWorkspaceRepos(value any) ([]byte, error) {
raw, err := json.Marshal(value)
if err != nil {
return nil, err
}
var repos []workspaceRepoRef
if err := json.Unmarshal(raw, &repos); err != nil {
return nil, fmt.Errorf("repos must be an array of repository objects: %w", err)
}
normalized := make([]workspaceRepoRef, 0, len(repos))
seen := make(map[string]struct{}, len(repos))
for i, repo := range repos {
repo.URL = strings.TrimSpace(repo.URL)
repo.Description = strings.TrimSpace(repo.Description)
if repo.URL == "" {
return nil, fmt.Errorf("repos[%d]: url is required", i)
}
if !isValidGitRepoURL(repo.URL) {
return nil, fmt.Errorf("repos[%d]: url must be a valid http(s) or ssh git URL", i)
}
if _, ok := seen[repo.URL]; ok {
continue
}
seen[repo.URL] = struct{}{}
normalized = append(normalized, repo)
}
out, err := json.Marshal(normalized)
if err != nil {
return nil, err
}
return out, nil
}
func (h *Handler) UpdateWorkspace(w http.ResponseWriter, r *http.Request) {
id := workspaceIDFromURL(r, "id")
idUUID, ok := parseUUIDOrBadRequest(w, id, "workspace id")
@@ -284,7 +326,11 @@ func (h *Handler) UpdateWorkspace(w http.ResponseWriter, r *http.Request) {
params.Settings = s
}
if req.Repos != nil {
reposJSON, _ := json.Marshal(req.Repos)
reposJSON, err := validateAndNormalizeWorkspaceRepos(req.Repos)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
params.Repos = reposJSON
}
if req.IssuePrefix != nil {

View File

@@ -303,6 +303,97 @@ VALUES ($1, $2, 'owner')
}
}
func TestUpdateWorkspace_ReposValidation(t *testing.T) {
ctx := context.Background()
const slug = "handler-tests-repos-validation"
_, _ = testPool.Exec(ctx, `DELETE FROM workspace WHERE slug = $1`, slug)
var wsID string
if err := testPool.QueryRow(ctx, `
INSERT INTO workspace (name, slug, description)
VALUES ($1, $2, $3)
RETURNING id
`, "Handler Test Repos Validation", slug, "UpdateWorkspace repos validation test").Scan(&wsID); err != nil {
t.Fatalf("create workspace: %v", err)
}
t.Cleanup(func() {
_, _ = testPool.Exec(context.Background(), `DELETE FROM workspace WHERE id = $1`, wsID)
})
if _, err := testPool.Exec(ctx, `
INSERT INTO member (workspace_id, user_id, role)
VALUES ($1, $2, 'owner')
`, wsID, testUserID); err != nil {
t.Fatalf("create owner member: %v", err)
}
t.Run("rejects invalid repo URLs without persisting", func(t *testing.T) {
w := httptest.NewRecorder()
req := newRequest("PATCH", "/api/workspaces/"+wsID, map[string]any{
"repos": []map[string]any{
{"url": "not-a-url"},
},
})
req = withURLParam(req, "id", wsID)
testHandler.UpdateWorkspace(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400 from invalid repos update, got %d: %s", w.Code, w.Body.String())
}
var raw []byte
if err := testPool.QueryRow(ctx, `SELECT repos FROM workspace WHERE id = $1`, wsID).Scan(&raw); err != nil {
t.Fatalf("read repos: %v", err)
}
if string(raw) != "[]" {
t.Fatalf("invalid repos update should not persist, got %s", raw)
}
})
t.Run("normalizes valid repos", func(t *testing.T) {
w := httptest.NewRecorder()
req := newRequest("PATCH", "/api/workspaces/"+wsID, map[string]any{
"repos": []map[string]any{
{
"url": " https://github.com/multica-ai/multica.git ",
"description": " main monorepo ",
},
{
"url": "https://github.com/multica-ai/multica.git",
},
{
"url": "git@github.com:multica-ai/multica-cloud.git",
},
},
})
req = withURLParam(req, "id", wsID)
testHandler.UpdateWorkspace(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200 from valid repos update, got %d: %s", w.Code, w.Body.String())
}
var raw []byte
if err := testPool.QueryRow(ctx, `SELECT repos FROM workspace WHERE id = $1`, wsID).Scan(&raw); err != nil {
t.Fatalf("read repos: %v", err)
}
var repos []workspaceRepoRef
if err := json.Unmarshal(raw, &repos); err != nil {
t.Fatalf("decode repos: %v", err)
}
if len(repos) != 2 {
t.Fatalf("expected duplicate URL to be deduped, got %d repos: %s", len(repos), raw)
}
if repos[0].URL != "https://github.com/multica-ai/multica.git" || repos[0].Description != "main monorepo" {
t.Fatalf("first repo not normalized: %+v", repos[0])
}
if repos[1].URL != "git@github.com:multica-ai/multica-cloud.git" {
t.Fatalf("second repo not preserved: %+v", repos[1])
}
})
}
// revocationFixture is a minimal (workspace, member-to-revoke, runtime,
// agent, queued-task, daemon-token) bundle used to drive the revocation
// tests. The "requester" is always testUserID (owner of the workspace) so