From 7db3e507d17bdeb6926f7c6e13c3164a48cc97f8 Mon Sep 17 00:00:00 2001 From: Bohan Jiang <52446949+Bohan-J@users.noreply.github.com> Date: Sat, 13 Jun 2026 01:22:45 +0800 Subject: [PATCH] feat(cli): manage workspace repo registry (#4067) Co-authored-by: J Co-authored-by: multica-agent --- server/cmd/multica/cmd_repo.go | 299 ++++++++++++++++++++++ server/cmd/multica/cmd_repo_test.go | 190 ++++++++++++++ server/internal/handler/workspace.go | 48 +++- server/internal/handler/workspace_test.go | 91 +++++++ 4 files changed, 627 insertions(+), 1 deletion(-) create mode 100644 server/cmd/multica/cmd_repo_test.go diff --git a/server/cmd/multica/cmd_repo.go b/server/cmd/multica/cmd_repo.go index 93d0ea3df..a396be74a 100644 --- a/server/cmd/multica/cmd_repo.go +++ b/server/cmd/multica/cmd_repo.go @@ -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 ", 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] diff --git a/server/cmd/multica/cmd_repo_test.go b/server/cmd/multica/cmd_repo_test.go new file mode 100644 index 000000000..f4fcc726b --- /dev/null +++ b/server/cmd/multica/cmd_repo_test.go @@ -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) + } +} diff --git a/server/internal/handler/workspace.go b/server/internal/handler/workspace.go index 3b4fc3ccb..ccc85b17a 100644 --- a/server/internal/handler/workspace.go +++ b/server/internal/handler/workspace.go @@ -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 { diff --git a/server/internal/handler/workspace_test.go b/server/internal/handler/workspace_test.go index 73b32bb6f..6aacc3251 100644 --- a/server/internal/handler/workspace_test.go +++ b/server/internal/handler/workspace_test.go @@ -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