mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-16 19:29:26 +02:00
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:
@@ -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]
|
||||
|
||||
|
||||
190
server/cmd/multica/cmd_repo_test.go
Normal file
190
server/cmd/multica/cmd_repo_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user