From 09dc0518c0b67e9322db244d1a5c03d9f4e81e67 Mon Sep 17 00:00:00 2001 From: Tynan Daly Date: Mon, 6 Apr 2026 13:10:12 -0700 Subject: [PATCH 1/4] feat: add repo command for managing repositories and repo tags Adds 'vers repo' with subcommands: - repo create/list/get/delete - manage repositories - repo visibility - set public/private - repo fork - fork a public repository - repo tag create/list/get/update/delete - manage tags within repos Uses Client.Execute directly since the SDK doesn't have repo endpoints yet. All DTOs are defined locally until SDK support lands. Tested against production API - all endpoints working. --- cmd/repo.go | 491 +++++++++++++++++++++++++ internal/handlers/repos.go | 293 +++++++++++++++ internal/presenters/repos_presenter.go | 82 +++++ internal/presenters/repos_types.go | 30 ++ 4 files changed, 896 insertions(+) create mode 100644 cmd/repo.go create mode 100644 internal/handlers/repos.go create mode 100644 internal/presenters/repos_presenter.go create mode 100644 internal/presenters/repos_types.go diff --git a/cmd/repo.go b/cmd/repo.go new file mode 100644 index 0000000..3e97591 --- /dev/null +++ b/cmd/repo.go @@ -0,0 +1,491 @@ +package cmd + +import ( + "context" + "fmt" + + "github.com/hdresearch/vers-cli/internal/handlers" + pres "github.com/hdresearch/vers-cli/internal/presenters" + "github.com/spf13/cobra" +) + +var repoCmd = &cobra.Command{ + Use: "repo", + Short: "Manage repositories", + Long: `Create, list, and manage repositories and their tags. +Repositories group related commits with named tags (e.g. "my-app:latest").`, +} + +// ── repo create ────────────────────────────────────────────────────── + +var repoCreateDescription string + +var repoCreateCmd = &cobra.Command{ + Use: "create ", + Short: "Create a new repository", + Long: `Create a named repository. Names must be alphanumeric with hyphens, underscores, or dots (1-64 chars).`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + apiCtx, cancel := context.WithTimeout(context.Background(), application.Timeouts.APIMedium) + defer cancel() + + resp, err := handlers.HandleRepoCreate(apiCtx, application, handlers.RepoCreateReq{ + Name: args[0], + Description: repoCreateDescription, + }) + if err != nil { + return err + } + fmt.Printf("✓ Repository '%s' created (%s)\n", resp.Name, resp.RepoID) + return nil + }, +} + +// ── repo list ──────────────────────────────────────────────────────── + +var ( + repoListQuiet bool + repoListFormat string +) + +var repoListCmd = &cobra.Command{ + Use: "list", + Short: "List all repositories", + Long: `List all repositories in your organization. + +Use -q/--quiet to output just names (one per line), useful for scripting. +Use --format json for machine-readable output.`, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + apiCtx, cancel := context.WithTimeout(context.Background(), application.Timeouts.APIMedium) + defer cancel() + + res, err := handlers.HandleRepoList(apiCtx, application, handlers.RepoListReq{}) + if err != nil { + return err + } + + format := pres.ParseFormat(repoListQuiet, repoListFormat) + switch format { + case pres.FormatQuiet: + names := make([]string, len(res.Repositories)) + for i, r := range res.Repositories { + names[i] = r.Name + } + pres.PrintQuiet(names) + case pres.FormatJSON: + pres.PrintJSON(res.Repositories) + default: + pres.RenderRepoList(application, pres.RepoListView{Repositories: res.Repositories}) + } + return nil + }, +} + +// ── repo get ───────────────────────────────────────────────────────── + +var repoGetFormat string + +var repoGetCmd = &cobra.Command{ + Use: "get ", + Short: "Get details of a repository", + Long: `Show detailed information about a specific repository. + +Use --format json for machine-readable output.`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + apiCtx, cancel := context.WithTimeout(context.Background(), application.Timeouts.APIMedium) + defer cancel() + + info, err := handlers.HandleRepoGet(apiCtx, application, handlers.RepoGetReq{ + Name: args[0], + }) + if err != nil { + return err + } + + format := pres.ParseFormat(false, repoGetFormat) + switch format { + case pres.FormatJSON: + pres.PrintJSON(info) + default: + pres.RenderRepoInfo(application, info) + } + return nil + }, +} + +// ── repo delete ────────────────────────────────────────────────────── + +var repoDeleteCmd = &cobra.Command{ + Use: "delete ...", + Short: "Delete one or more repositories", + Long: `Delete one or more repositories. This also deletes all tags within those repositories. +The commits themselves are NOT deleted. + +Examples: + vers repo delete my-app + vers repo delete my-app staging-env + vers repo delete $(vers repo list -q) # delete all repos`, + Args: cobra.MinimumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + apiCtx, cancel := context.WithTimeout(context.Background(), application.Timeouts.APIMedium) + defer cancel() + + var firstErr error + for _, name := range args { + err := handlers.HandleRepoDelete(apiCtx, application, handlers.RepoDeleteReq{ + Name: name, + }) + if err != nil { + fmt.Fprintf(cmd.ErrOrStderr(), "✗ Failed to delete repository '%s': %v\n", name, err) + if firstErr == nil { + firstErr = err + } + continue + } + fmt.Printf("✓ Repository '%s' deleted\n", name) + } + return firstErr + }, +} + +// ── repo visibility ────────────────────────────────────────────────── + +var repoVisibilityPublic bool + +var repoVisibilityCmd = &cobra.Command{ + Use: "visibility ", + Short: "Set repository visibility", + Long: `Set a repository's visibility to public or private. + +Examples: + vers repo visibility my-app --public # make public + vers repo visibility my-app --public=false # make private`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + apiCtx, cancel := context.WithTimeout(context.Background(), application.Timeouts.APIMedium) + defer cancel() + + err := handlers.HandleRepoSetVisibility(apiCtx, application, handlers.RepoSetVisibilityReq{ + Name: args[0], + IsPublic: repoVisibilityPublic, + }) + if err != nil { + return err + } + + vis := "private" + if repoVisibilityPublic { + vis = "public" + } + fmt.Printf("✓ Repository '%s' is now %s\n", args[0], vis) + return nil + }, +} + +// ── repo fork ──────────────────────────────────────────────────────── + +var ( + repoForkRepoName string + repoForkTagName string +) + +var repoForkCmd = &cobra.Command{ + Use: "fork /:", + Short: "Fork a public repository", + Long: `Fork a public repository into your organization. Creates a new VM, commits it, +and creates a repository with a tag pointing to the commit. + +Examples: + vers repo fork acme/ubuntu:latest + vers repo fork acme/ubuntu:latest --repo-name my-ubuntu --tag-name v1`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + org, repo, tag, err := parseRepoRef(args[0]) + if err != nil { + return err + } + + apiCtx, cancel := context.WithTimeout(context.Background(), application.Timeouts.APILong) + defer cancel() + + resp, err := handlers.HandleRepoFork(apiCtx, application, handlers.RepoForkReq{ + SourceOrg: org, + SourceRepo: repo, + SourceTag: tag, + RepoName: repoForkRepoName, + TagName: repoForkTagName, + }) + if err != nil { + return err + } + fmt.Printf("✓ Forked → %s\n", resp.Reference) + fmt.Printf(" VM: %s\n", resp.VmID) + fmt.Printf(" Commit: %s\n", resp.CommitID) + return nil + }, +} + +// ── repo tag (subcommand group) ────────────────────────────────────── + +var repoTagCmd = &cobra.Command{ + Use: "tag", + Short: "Manage repository tags", + Long: `Create, list, update, and delete tags within a repository.`, +} + +var repoTagCreateDescription string + +var repoTagCreateCmd = &cobra.Command{ + Use: "create ", + Short: "Create a tag in a repository", + Long: `Create a named tag within a repository that points to a specific commit.`, + Args: cobra.ExactArgs(3), + RunE: func(cmd *cobra.Command, args []string) error { + apiCtx, cancel := context.WithTimeout(context.Background(), application.Timeouts.APIMedium) + defer cancel() + + resp, err := handlers.HandleRepoTagCreate(apiCtx, application, handlers.RepoTagCreateReq{ + RepoName: args[0], + TagName: args[1], + CommitID: args[2], + Description: repoTagCreateDescription, + }) + if err != nil { + return err + } + fmt.Printf("✓ Tag created → %s\n", resp.Reference) + return nil + }, +} + +var ( + repoTagListQuiet bool + repoTagListFormat string +) + +var repoTagListCmd = &cobra.Command{ + Use: "list ", + Short: "List tags in a repository", + Long: `List all tags within a repository. + +Use -q/--quiet for just tag names. Use --format json for machine-readable output.`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + apiCtx, cancel := context.WithTimeout(context.Background(), application.Timeouts.APIMedium) + defer cancel() + + res, err := handlers.HandleRepoTagList(apiCtx, application, handlers.RepoTagListReq{ + RepoName: args[0], + }) + if err != nil { + return err + } + + format := pres.ParseFormat(repoTagListQuiet, repoTagListFormat) + switch format { + case pres.FormatQuiet: + names := make([]string, len(res.Tags)) + for i, t := range res.Tags { + names[i] = t.TagName + } + pres.PrintQuiet(names) + case pres.FormatJSON: + pres.PrintJSON(res.Tags) + default: + pres.RenderRepoTagList(application, pres.RepoTagListView{ + Repository: res.Repository, + Tags: res.Tags, + }) + } + return nil + }, +} + +var repoTagGetFormat string + +var repoTagGetCmd = &cobra.Command{ + Use: "get ", + Short: "Get details of a repository tag", + Long: `Show detailed information about a specific tag within a repository. + +Use --format json for machine-readable output.`, + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + apiCtx, cancel := context.WithTimeout(context.Background(), application.Timeouts.APIMedium) + defer cancel() + + info, err := handlers.HandleRepoTagGet(apiCtx, application, handlers.RepoTagGetReq{ + RepoName: args[0], + TagName: args[1], + }) + if err != nil { + return err + } + + format := pres.ParseFormat(false, repoTagGetFormat) + switch format { + case pres.FormatJSON: + pres.PrintJSON(info) + default: + pres.RenderRepoTagInfo(application, info) + } + return nil + }, +} + +var ( + repoTagUpdateCommit string + repoTagUpdateDescription string +) + +var repoTagUpdateCmd = &cobra.Command{ + Use: "update ", + Short: "Update a repository tag", + Long: `Move a tag to a different commit, or update its description.`, + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + if repoTagUpdateCommit == "" && repoTagUpdateDescription == "" { + return fmt.Errorf("at least one of --commit or --description must be provided") + } + + apiCtx, cancel := context.WithTimeout(context.Background(), application.Timeouts.APIMedium) + defer cancel() + + err := handlers.HandleRepoTagUpdate(apiCtx, application, handlers.RepoTagUpdateReq{ + RepoName: args[0], + TagName: args[1], + CommitID: repoTagUpdateCommit, + Description: repoTagUpdateDescription, + }) + if err != nil { + return err + } + fmt.Printf("✓ Tag '%s' in '%s' updated\n", args[1], args[0]) + return nil + }, +} + +var repoTagDeleteCmd = &cobra.Command{ + Use: "delete ...", + Short: "Delete one or more tags from a repository", + Long: `Delete one or more tags from a repository. The commits are not deleted. + +Examples: + vers repo tag delete my-app staging + vers repo tag delete my-app v1 v2 v3`, + Args: cobra.MinimumNArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + repoName := args[0] + tagNames := args[1:] + + apiCtx, cancel := context.WithTimeout(context.Background(), application.Timeouts.APIMedium) + defer cancel() + + var firstErr error + for _, name := range tagNames { + err := handlers.HandleRepoTagDelete(apiCtx, application, handlers.RepoTagDeleteReq{ + RepoName: repoName, + TagName: name, + }) + if err != nil { + fmt.Fprintf(cmd.ErrOrStderr(), "✗ Failed to delete tag '%s': %v\n", name, err) + if firstErr == nil { + firstErr = err + } + continue + } + fmt.Printf("✓ Tag '%s' deleted from '%s'\n", name, repoName) + } + return firstErr + }, +} + +// ── helpers ────────────────────────────────────────────────────────── + +// parseRepoRef parses "org/repo:tag" into its components. +func parseRepoRef(ref string) (org, repo, tag string, err error) { + // Find the org/repo split + slashIdx := -1 + for i, c := range ref { + if c == '/' { + slashIdx = i + break + } + } + if slashIdx <= 0 { + return "", "", "", fmt.Errorf("invalid reference '%s': expected format org/repo:tag", ref) + } + org = ref[:slashIdx] + rest := ref[slashIdx+1:] + + // Find the repo:tag split + colonIdx := -1 + for i, c := range rest { + if c == ':' { + colonIdx = i + break + } + } + if colonIdx <= 0 { + return "", "", "", fmt.Errorf("invalid reference '%s': expected format org/repo:tag", ref) + } + repo = rest[:colonIdx] + tag = rest[colonIdx+1:] + + if org == "" || repo == "" || tag == "" { + return "", "", "", fmt.Errorf("invalid reference '%s': expected format org/repo:tag", ref) + } + return org, repo, tag, nil +} + +// ── init ───────────────────────────────────────────────────────────── + +func init() { + rootCmd.AddCommand(repoCmd) + + // repo create + repoCreateCmd.Flags().StringVarP(&repoCreateDescription, "description", "d", "", "Description for the repository") + repoCmd.AddCommand(repoCreateCmd) + + // repo list + repoListCmd.Flags().BoolVarP(&repoListQuiet, "quiet", "q", false, "Only display repository names") + repoListCmd.Flags().StringVar(&repoListFormat, "format", "", "Output format (json)") + repoCmd.AddCommand(repoListCmd) + + // repo get + repoGetCmd.Flags().StringVar(&repoGetFormat, "format", "", "Output format (json)") + repoCmd.AddCommand(repoGetCmd) + + // repo delete + repoCmd.AddCommand(repoDeleteCmd) + + // repo visibility + repoVisibilityCmd.Flags().BoolVar(&repoVisibilityPublic, "public", false, "Set to public (use --public=false for private)") + repoCmd.AddCommand(repoVisibilityCmd) + + // repo fork + repoForkCmd.Flags().StringVar(&repoForkRepoName, "repo-name", "", "Name for the forked repository (default: source name)") + repoForkCmd.Flags().StringVar(&repoForkTagName, "tag-name", "", "Tag name in the new repo (default: source tag)") + repoCmd.AddCommand(repoForkCmd) + + // repo tag subcommands + repoCmd.AddCommand(repoTagCmd) + + repoTagCreateCmd.Flags().StringVarP(&repoTagCreateDescription, "description", "d", "", "Description for the tag") + repoTagCmd.AddCommand(repoTagCreateCmd) + + repoTagListCmd.Flags().BoolVarP(&repoTagListQuiet, "quiet", "q", false, "Only display tag names") + repoTagListCmd.Flags().StringVar(&repoTagListFormat, "format", "", "Output format (json)") + repoTagCmd.AddCommand(repoTagListCmd) + + repoTagGetCmd.Flags().StringVar(&repoTagGetFormat, "format", "", "Output format (json)") + repoTagCmd.AddCommand(repoTagGetCmd) + + repoTagUpdateCmd.Flags().StringVar(&repoTagUpdateCommit, "commit", "", "Move tag to this commit ID") + repoTagUpdateCmd.Flags().StringVarP(&repoTagUpdateDescription, "description", "d", "", "New description for the tag") + repoTagCmd.AddCommand(repoTagUpdateCmd) + + repoTagCmd.AddCommand(repoTagDeleteCmd) +} diff --git a/internal/handlers/repos.go b/internal/handlers/repos.go new file mode 100644 index 0000000..96c1311 --- /dev/null +++ b/internal/handlers/repos.go @@ -0,0 +1,293 @@ +package handlers + +import ( + "context" + "fmt" + "net/http" + + "github.com/hdresearch/vers-cli/internal/app" + pres "github.com/hdresearch/vers-cli/internal/presenters" +) + +// ── DTOs (until the SDK adds repository support) ───────────────────── + +type createRepoResponse struct { + RepoID string `json:"repo_id"` + Name string `json:"name"` +} + +type listReposResponse struct { + Repositories []pres.RepoInfo `json:"repositories"` +} + +type listRepoTagsResponse struct { + Repository string `json:"repository"` + Tags []pres.RepoTagInfo `json:"tags"` +} + +type createRepoTagResponse struct { + TagID string `json:"tag_id"` + Reference string `json:"reference"` + CommitID string `json:"commit_id"` +} + +type setVisibilityRequest struct { + IsPublic bool `json:"is_public"` +} + +type forkRepoResponse struct { + VmID string `json:"vm_id"` + CommitID string `json:"commit_id"` + RepoName string `json:"repo_name"` + TagName string `json:"tag_name"` + Reference string `json:"reference"` +} + +// ── Handlers ───────────────────────────────────────────────────────── + +// RepoCreateReq is the request for creating a repository. +type RepoCreateReq struct { + Name string + Description string +} + +func HandleRepoCreate(ctx context.Context, a *app.App, r RepoCreateReq) (*createRepoResponse, error) { + if r.Name == "" { + return nil, fmt.Errorf("repository name is required") + } + body := map[string]interface{}{"name": r.Name} + if r.Description != "" { + body["description"] = r.Description + } + var res createRepoResponse + err := a.Client.Post(ctx, "api/v1/repositories", body, &res) + if err != nil { + return nil, fmt.Errorf("failed to create repository '%s': %w", r.Name, err) + } + return &res, nil +} + +// RepoListReq is the request for listing repositories. +type RepoListReq struct{} + +func HandleRepoList(ctx context.Context, a *app.App, _ RepoListReq) (*listReposResponse, error) { + var res listReposResponse + err := a.Client.Get(ctx, "api/v1/repositories", nil, &res) + if err != nil { + return nil, fmt.Errorf("failed to list repositories: %w", err) + } + return &res, nil +} + +// RepoGetReq is the request for getting a repository. +type RepoGetReq struct { + Name string +} + +func HandleRepoGet(ctx context.Context, a *app.App, r RepoGetReq) (*pres.RepoInfo, error) { + if r.Name == "" { + return nil, fmt.Errorf("repository name is required") + } + var res pres.RepoInfo + err := a.Client.Get(ctx, fmt.Sprintf("api/v1/repositories/%s", r.Name), nil, &res) + if err != nil { + return nil, fmt.Errorf("failed to get repository '%s': %w", r.Name, err) + } + return &res, nil +} + +// RepoDeleteReq is the request for deleting a repository. +type RepoDeleteReq struct { + Name string +} + +func HandleRepoDelete(ctx context.Context, a *app.App, r RepoDeleteReq) error { + if r.Name == "" { + return fmt.Errorf("repository name is required") + } + err := a.Client.Delete(ctx, fmt.Sprintf("api/v1/repositories/%s", r.Name), nil, nil) + if err != nil { + return fmt.Errorf("failed to delete repository '%s': %w", r.Name, err) + } + return nil +} + +// RepoSetVisibilityReq is the request for setting repository visibility. +type RepoSetVisibilityReq struct { + Name string + IsPublic bool +} + +func HandleRepoSetVisibility(ctx context.Context, a *app.App, r RepoSetVisibilityReq) error { + if r.Name == "" { + return fmt.Errorf("repository name is required") + } + body := setVisibilityRequest{IsPublic: r.IsPublic} + err := a.Client.Execute(ctx, http.MethodPut, fmt.Sprintf("api/v1/repositories/%s/visibility", r.Name), body, nil) + if err != nil { + return fmt.Errorf("failed to set visibility for '%s': %w", r.Name, err) + } + return nil +} + +// ── Repo Tag Handlers ──────────────────────────────────────────────── + +// RepoTagCreateReq is the request for creating a tag in a repository. +type RepoTagCreateReq struct { + RepoName string + TagName string + CommitID string + Description string +} + +func HandleRepoTagCreate(ctx context.Context, a *app.App, r RepoTagCreateReq) (*createRepoTagResponse, error) { + if r.RepoName == "" { + return nil, fmt.Errorf("repository name is required") + } + if r.TagName == "" { + return nil, fmt.Errorf("tag name is required") + } + if r.CommitID == "" { + return nil, fmt.Errorf("commit ID is required") + } + body := map[string]interface{}{ + "tag_name": r.TagName, + "commit_id": r.CommitID, + } + if r.Description != "" { + body["description"] = r.Description + } + var res createRepoTagResponse + err := a.Client.Post(ctx, fmt.Sprintf("api/v1/repositories/%s/tags", r.RepoName), body, &res) + if err != nil { + return nil, fmt.Errorf("failed to create tag '%s' in '%s': %w", r.TagName, r.RepoName, err) + } + return &res, nil +} + +// RepoTagListReq is the request for listing tags in a repository. +type RepoTagListReq struct { + RepoName string +} + +func HandleRepoTagList(ctx context.Context, a *app.App, r RepoTagListReq) (*listRepoTagsResponse, error) { + if r.RepoName == "" { + return nil, fmt.Errorf("repository name is required") + } + var res listRepoTagsResponse + err := a.Client.Get(ctx, fmt.Sprintf("api/v1/repositories/%s/tags", r.RepoName), nil, &res) + if err != nil { + return nil, fmt.Errorf("failed to list tags for '%s': %w", r.RepoName, err) + } + return &res, nil +} + +// RepoTagGetReq is the request for getting a tag in a repository. +type RepoTagGetReq struct { + RepoName string + TagName string +} + +func HandleRepoTagGet(ctx context.Context, a *app.App, r RepoTagGetReq) (*pres.RepoTagInfo, error) { + if r.RepoName == "" { + return nil, fmt.Errorf("repository name is required") + } + if r.TagName == "" { + return nil, fmt.Errorf("tag name is required") + } + var res pres.RepoTagInfo + err := a.Client.Get(ctx, fmt.Sprintf("api/v1/repositories/%s/tags/%s", r.RepoName, r.TagName), nil, &res) + if err != nil { + return nil, fmt.Errorf("failed to get tag '%s' in '%s': %w", r.TagName, r.RepoName, err) + } + return &res, nil +} + +// RepoTagUpdateReq is the request for updating a tag in a repository. +type RepoTagUpdateReq struct { + RepoName string + TagName string + CommitID string + Description string +} + +func HandleRepoTagUpdate(ctx context.Context, a *app.App, r RepoTagUpdateReq) error { + if r.RepoName == "" { + return fmt.Errorf("repository name is required") + } + if r.TagName == "" { + return fmt.Errorf("tag name is required") + } + body := map[string]interface{}{} + if r.CommitID != "" { + body["commit_id"] = r.CommitID + } + if r.Description != "" { + body["description"] = r.Description + } + err := a.Client.Execute(ctx, http.MethodPut, fmt.Sprintf("api/v1/repositories/%s/tags/%s", r.RepoName, r.TagName), body, nil) + if err != nil { + return fmt.Errorf("failed to update tag '%s' in '%s': %w", r.TagName, r.RepoName, err) + } + return nil +} + +// RepoTagDeleteReq is the request for deleting a tag in a repository. +type RepoTagDeleteReq struct { + RepoName string + TagName string +} + +func HandleRepoTagDelete(ctx context.Context, a *app.App, r RepoTagDeleteReq) error { + if r.RepoName == "" { + return fmt.Errorf("repository name is required") + } + if r.TagName == "" { + return fmt.Errorf("tag name is required") + } + err := a.Client.Delete(ctx, fmt.Sprintf("api/v1/repositories/%s/tags/%s", r.RepoName, r.TagName), nil, nil) + if err != nil { + return fmt.Errorf("failed to delete tag '%s' in '%s': %w", r.TagName, r.RepoName, err) + } + return nil +} + +// ── Fork Handler ───────────────────────────────────────────────────── + +// RepoForkReq is the request for forking a public repository. +type RepoForkReq struct { + SourceOrg string + SourceRepo string + SourceTag string + RepoName string + TagName string +} + +func HandleRepoFork(ctx context.Context, a *app.App, r RepoForkReq) (*forkRepoResponse, error) { + if r.SourceOrg == "" { + return nil, fmt.Errorf("source organization is required") + } + if r.SourceRepo == "" { + return nil, fmt.Errorf("source repository is required") + } + if r.SourceTag == "" { + return nil, fmt.Errorf("source tag is required") + } + body := map[string]interface{}{ + "source_org": r.SourceOrg, + "source_repo": r.SourceRepo, + "source_tag": r.SourceTag, + } + if r.RepoName != "" { + body["repo_name"] = r.RepoName + } + if r.TagName != "" { + body["tag_name"] = r.TagName + } + var res forkRepoResponse + err := a.Client.Post(ctx, "api/v1/repositories/fork", body, &res) + if err != nil { + return nil, fmt.Errorf("failed to fork %s/%s:%s: %w", r.SourceOrg, r.SourceRepo, r.SourceTag, err) + } + return &res, nil +} diff --git a/internal/presenters/repos_presenter.go b/internal/presenters/repos_presenter.go new file mode 100644 index 0000000..0bc0a05 --- /dev/null +++ b/internal/presenters/repos_presenter.go @@ -0,0 +1,82 @@ +package presenters + +import ( + "fmt" + + "github.com/hdresearch/vers-cli/internal/app" +) + +func RenderRepoList(_ *app.App, v RepoListView) { + if len(v.Repositories) == 0 { + fmt.Println("No repositories found") + fmt.Println("Create one with: vers repo create ") + return + } + + fmt.Printf("%-24s %-10s %-20s %s\n", "NAME", "VISIBILITY", "CREATED", "DESCRIPTION") + for _, r := range v.Repositories { + vis := "private" + if r.IsPublic { + vis = "public" + } + desc := r.Description + if desc == "" { + desc = "-" + } + fmt.Printf("%-24s %-10s %-20s %s\n", + r.Name, + vis, + r.CreatedAt.Format("2006-01-02 15:04:05"), + desc, + ) + } +} + +func RenderRepoInfo(_ *app.App, r *RepoInfo) { + vis := "private" + if r.IsPublic { + vis = "public" + } + fmt.Printf("Name: %s\n", r.Name) + fmt.Printf("Repo ID: %s\n", r.RepoID) + fmt.Printf("Visibility: %s\n", vis) + fmt.Printf("Created: %s\n", r.CreatedAt.Format("2006-01-02 15:04:05")) + if r.Description != "" { + fmt.Printf("Description: %s\n", r.Description) + } +} + +func RenderRepoTagList(_ *app.App, v RepoTagListView) { + if len(v.Tags) == 0 { + fmt.Printf("No tags found in repository '%s'\n", v.Repository) + fmt.Printf("Create one with: vers repo tag create %s \n", v.Repository) + return + } + + fmt.Printf("Repository: %s\n\n", v.Repository) + fmt.Printf("%-20s %-38s %-20s %s\n", "TAG", "COMMIT", "CREATED", "DESCRIPTION") + for _, t := range v.Tags { + desc := t.Description + if desc == "" { + desc = "-" + } + fmt.Printf("%-20s %-38s %-20s %s\n", + t.TagName, + t.CommitID, + t.CreatedAt.Format("2006-01-02 15:04:05"), + desc, + ) + } +} + +func RenderRepoTagInfo(_ *app.App, t *RepoTagInfo) { + fmt.Printf("Tag: %s\n", t.TagName) + fmt.Printf("Tag ID: %s\n", t.TagID) + fmt.Printf("Reference: %s\n", t.Reference) + fmt.Printf("Commit: %s\n", t.CommitID) + fmt.Printf("Created: %s\n", t.CreatedAt.Format("2006-01-02 15:04:05")) + fmt.Printf("Updated: %s\n", t.UpdatedAt.Format("2006-01-02 15:04:05")) + if t.Description != "" { + fmt.Printf("Description: %s\n", t.Description) + } +} diff --git a/internal/presenters/repos_types.go b/internal/presenters/repos_types.go new file mode 100644 index 0000000..a69f3c6 --- /dev/null +++ b/internal/presenters/repos_types.go @@ -0,0 +1,30 @@ +package presenters + +import "time" + +type RepoInfo struct { + RepoID string `json:"repo_id"` + Name string `json:"name"` + Description string `json:"description"` + IsPublic bool `json:"is_public"` + CreatedAt time.Time `json:"created_at"` +} + +type RepoTagInfo struct { + TagID string `json:"tag_id"` + TagName string `json:"tag_name"` + Reference string `json:"reference"` + CommitID string `json:"commit_id"` + Description string `json:"description"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type RepoListView struct { + Repositories []RepoInfo +} + +type RepoTagListView struct { + Repository string + Tags []RepoTagInfo +} From c820e17647385a6edcd6eb9aa738ae8f4f3e8d9f Mon Sep 17 00:00:00 2001 From: Tynan Daly Date: Mon, 6 Apr 2026 13:23:45 -0700 Subject: [PATCH 2/4] fix: gofmt alignment --- internal/handlers/repos.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/handlers/repos.go b/internal/handlers/repos.go index 96c1311..e5b75ad 100644 --- a/internal/handlers/repos.go +++ b/internal/handlers/repos.go @@ -21,7 +21,7 @@ type listReposResponse struct { } type listRepoTagsResponse struct { - Repository string `json:"repository"` + Repository string `json:"repository"` Tags []pres.RepoTagInfo `json:"tags"` } From 811b0fe681788e13af91d040a24b81d9defde46d Mon Sep 17 00:00:00 2001 From: Tynan Daly Date: Mon, 6 Apr 2026 14:27:38 -0700 Subject: [PATCH 3/4] refactor: use vers-sdk-go v0.1.0-alpha.32 for repo commands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace hand-rolled DTOs and Client.Execute calls with the generated SDK types (RepositoryService, vers.RepositoryInfo, vers.RepoTagInfo, etc). Net -55 lines — handlers are simpler and type-safe now. --- go.mod | 2 +- go.sum | 2 + internal/handlers/repos.go | 152 +++++++++---------------- internal/presenters/repos_presenter.go | 5 +- internal/presenters/repos_types.go | 24 +--- 5 files changed, 65 insertions(+), 120 deletions(-) diff --git a/go.mod b/go.mod index c5c535b..f79910a 100644 --- a/go.mod +++ b/go.mod @@ -22,7 +22,7 @@ require ( ) require ( - github.com/hdresearch/vers-sdk-go v0.1.0-alpha.31 + github.com/hdresearch/vers-sdk-go v0.1.0-alpha.32 github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/spf13/pflag v1.0.6 // indirect github.com/tidwall/gjson v1.18.0 // indirect diff --git a/go.sum b/go.sum index b35ed3f..85752c8 100644 --- a/go.sum +++ b/go.sum @@ -9,6 +9,8 @@ github.com/google/jsonschema-go v0.2.3 h1:dkP3B96OtZKKFvdrUSaDkL+YDx8Uw9uC4Y+euk github.com/google/jsonschema-go v0.2.3/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= github.com/hdresearch/vers-sdk-go v0.1.0-alpha.31 h1:JXEU1lXHzfIpi5Aq5LQjtdSnZDGTqqTZK4TcbAJTfcQ= github.com/hdresearch/vers-sdk-go v0.1.0-alpha.31/go.mod h1:aJoQGYzJHXdbj7uhCekUZaxbMu+XhVMOCtVQEdA0NFI= +github.com/hdresearch/vers-sdk-go v0.1.0-alpha.32 h1:b1+KSJbLQgsC9KEnoQxrbHq/sxtHDM/cB1+XwHMYOBs= +github.com/hdresearch/vers-sdk-go v0.1.0-alpha.32/go.mod h1:aJoQGYzJHXdbj7uhCekUZaxbMu+XhVMOCtVQEdA0NFI= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= diff --git a/internal/handlers/repos.go b/internal/handlers/repos.go index e5b75ad..bdc522c 100644 --- a/internal/handlers/repos.go +++ b/internal/handlers/repos.go @@ -3,100 +3,62 @@ package handlers import ( "context" "fmt" - "net/http" "github.com/hdresearch/vers-cli/internal/app" - pres "github.com/hdresearch/vers-cli/internal/presenters" + vers "github.com/hdresearch/vers-sdk-go" ) -// ── DTOs (until the SDK adds repository support) ───────────────────── +// ── Repo Handlers ──────────────────────────────────────────────────── -type createRepoResponse struct { - RepoID string `json:"repo_id"` - Name string `json:"name"` -} - -type listReposResponse struct { - Repositories []pres.RepoInfo `json:"repositories"` -} - -type listRepoTagsResponse struct { - Repository string `json:"repository"` - Tags []pres.RepoTagInfo `json:"tags"` -} - -type createRepoTagResponse struct { - TagID string `json:"tag_id"` - Reference string `json:"reference"` - CommitID string `json:"commit_id"` -} - -type setVisibilityRequest struct { - IsPublic bool `json:"is_public"` -} - -type forkRepoResponse struct { - VmID string `json:"vm_id"` - CommitID string `json:"commit_id"` - RepoName string `json:"repo_name"` - TagName string `json:"tag_name"` - Reference string `json:"reference"` -} - -// ── Handlers ───────────────────────────────────────────────────────── - -// RepoCreateReq is the request for creating a repository. type RepoCreateReq struct { Name string Description string } -func HandleRepoCreate(ctx context.Context, a *app.App, r RepoCreateReq) (*createRepoResponse, error) { +func HandleRepoCreate(ctx context.Context, a *app.App, r RepoCreateReq) (*vers.CreateRepositoryResponse, error) { if r.Name == "" { return nil, fmt.Errorf("repository name is required") } - body := map[string]interface{}{"name": r.Name} + params := vers.RepositoryNewParams{ + CreateRepositoryRequest: vers.CreateRepositoryRequestParam{ + Name: vers.F(r.Name), + }, + } if r.Description != "" { - body["description"] = r.Description + params.CreateRepositoryRequest.Description = vers.F(r.Description) } - var res createRepoResponse - err := a.Client.Post(ctx, "api/v1/repositories", body, &res) + resp, err := a.Client.Repositories.New(ctx, params) if err != nil { return nil, fmt.Errorf("failed to create repository '%s': %w", r.Name, err) } - return &res, nil + return resp, nil } -// RepoListReq is the request for listing repositories. type RepoListReq struct{} -func HandleRepoList(ctx context.Context, a *app.App, _ RepoListReq) (*listReposResponse, error) { - var res listReposResponse - err := a.Client.Get(ctx, "api/v1/repositories", nil, &res) +func HandleRepoList(ctx context.Context, a *app.App, _ RepoListReq) (*vers.ListRepositoriesResponse, error) { + resp, err := a.Client.Repositories.List(ctx) if err != nil { return nil, fmt.Errorf("failed to list repositories: %w", err) } - return &res, nil + return resp, nil } -// RepoGetReq is the request for getting a repository. type RepoGetReq struct { Name string } -func HandleRepoGet(ctx context.Context, a *app.App, r RepoGetReq) (*pres.RepoInfo, error) { +func HandleRepoGet(ctx context.Context, a *app.App, r RepoGetReq) (*vers.RepositoryInfo, error) { if r.Name == "" { return nil, fmt.Errorf("repository name is required") } - var res pres.RepoInfo - err := a.Client.Get(ctx, fmt.Sprintf("api/v1/repositories/%s", r.Name), nil, &res) + resp, err := a.Client.Repositories.Get(ctx, r.Name) if err != nil { return nil, fmt.Errorf("failed to get repository '%s': %w", r.Name, err) } - return &res, nil + return resp, nil } -// RepoDeleteReq is the request for deleting a repository. type RepoDeleteReq struct { Name string } @@ -105,14 +67,13 @@ func HandleRepoDelete(ctx context.Context, a *app.App, r RepoDeleteReq) error { if r.Name == "" { return fmt.Errorf("repository name is required") } - err := a.Client.Delete(ctx, fmt.Sprintf("api/v1/repositories/%s", r.Name), nil, nil) + err := a.Client.Repositories.Delete(ctx, r.Name) if err != nil { return fmt.Errorf("failed to delete repository '%s': %w", r.Name, err) } return nil } -// RepoSetVisibilityReq is the request for setting repository visibility. type RepoSetVisibilityReq struct { Name string IsPublic bool @@ -122,8 +83,11 @@ func HandleRepoSetVisibility(ctx context.Context, a *app.App, r RepoSetVisibilit if r.Name == "" { return fmt.Errorf("repository name is required") } - body := setVisibilityRequest{IsPublic: r.IsPublic} - err := a.Client.Execute(ctx, http.MethodPut, fmt.Sprintf("api/v1/repositories/%s/visibility", r.Name), body, nil) + err := a.Client.Repositories.SetVisibility(ctx, r.Name, vers.RepositorySetVisibilityParams{ + SetRepositoryVisibilityRequest: vers.SetRepositoryVisibilityRequestParam{ + IsPublic: vers.F(r.IsPublic), + }, + }) if err != nil { return fmt.Errorf("failed to set visibility for '%s': %w", r.Name, err) } @@ -132,7 +96,6 @@ func HandleRepoSetVisibility(ctx context.Context, a *app.App, r RepoSetVisibilit // ── Repo Tag Handlers ──────────────────────────────────────────────── -// RepoTagCreateReq is the request for creating a tag in a repository. type RepoTagCreateReq struct { RepoName string TagName string @@ -140,7 +103,7 @@ type RepoTagCreateReq struct { Description string } -func HandleRepoTagCreate(ctx context.Context, a *app.App, r RepoTagCreateReq) (*createRepoTagResponse, error) { +func HandleRepoTagCreate(ctx context.Context, a *app.App, r RepoTagCreateReq) (*vers.CreateRepoTagResponse, error) { if r.RepoName == "" { return nil, fmt.Errorf("repository name is required") } @@ -150,60 +113,56 @@ func HandleRepoTagCreate(ctx context.Context, a *app.App, r RepoTagCreateReq) (* if r.CommitID == "" { return nil, fmt.Errorf("commit ID is required") } - body := map[string]interface{}{ - "tag_name": r.TagName, - "commit_id": r.CommitID, + params := vers.RepositoryNewTagParams{ + CreateRepoTagRequest: vers.CreateRepoTagRequestParam{ + TagName: vers.F(r.TagName), + CommitID: vers.F(r.CommitID), + }, } if r.Description != "" { - body["description"] = r.Description + params.CreateRepoTagRequest.Description = vers.F(r.Description) } - var res createRepoTagResponse - err := a.Client.Post(ctx, fmt.Sprintf("api/v1/repositories/%s/tags", r.RepoName), body, &res) + resp, err := a.Client.Repositories.NewTag(ctx, r.RepoName, params) if err != nil { return nil, fmt.Errorf("failed to create tag '%s' in '%s': %w", r.TagName, r.RepoName, err) } - return &res, nil + return resp, nil } -// RepoTagListReq is the request for listing tags in a repository. type RepoTagListReq struct { RepoName string } -func HandleRepoTagList(ctx context.Context, a *app.App, r RepoTagListReq) (*listRepoTagsResponse, error) { +func HandleRepoTagList(ctx context.Context, a *app.App, r RepoTagListReq) (*vers.ListRepoTagsResponse, error) { if r.RepoName == "" { return nil, fmt.Errorf("repository name is required") } - var res listRepoTagsResponse - err := a.Client.Get(ctx, fmt.Sprintf("api/v1/repositories/%s/tags", r.RepoName), nil, &res) + resp, err := a.Client.Repositories.ListTags(ctx, r.RepoName) if err != nil { return nil, fmt.Errorf("failed to list tags for '%s': %w", r.RepoName, err) } - return &res, nil + return resp, nil } -// RepoTagGetReq is the request for getting a tag in a repository. type RepoTagGetReq struct { RepoName string TagName string } -func HandleRepoTagGet(ctx context.Context, a *app.App, r RepoTagGetReq) (*pres.RepoTagInfo, error) { +func HandleRepoTagGet(ctx context.Context, a *app.App, r RepoTagGetReq) (*vers.RepoTagInfo, error) { if r.RepoName == "" { return nil, fmt.Errorf("repository name is required") } if r.TagName == "" { return nil, fmt.Errorf("tag name is required") } - var res pres.RepoTagInfo - err := a.Client.Get(ctx, fmt.Sprintf("api/v1/repositories/%s/tags/%s", r.RepoName, r.TagName), nil, &res) + resp, err := a.Client.Repositories.GetTag(ctx, r.RepoName, r.TagName) if err != nil { return nil, fmt.Errorf("failed to get tag '%s' in '%s': %w", r.TagName, r.RepoName, err) } - return &res, nil + return resp, nil } -// RepoTagUpdateReq is the request for updating a tag in a repository. type RepoTagUpdateReq struct { RepoName string TagName string @@ -218,21 +177,22 @@ func HandleRepoTagUpdate(ctx context.Context, a *app.App, r RepoTagUpdateReq) er if r.TagName == "" { return fmt.Errorf("tag name is required") } - body := map[string]interface{}{} + params := vers.RepositoryUpdateTagParams{ + UpdateRepoTagRequest: vers.UpdateRepoTagRequestParam{}, + } if r.CommitID != "" { - body["commit_id"] = r.CommitID + params.UpdateRepoTagRequest.CommitID = vers.F(r.CommitID) } if r.Description != "" { - body["description"] = r.Description + params.UpdateRepoTagRequest.Description = vers.F(r.Description) } - err := a.Client.Execute(ctx, http.MethodPut, fmt.Sprintf("api/v1/repositories/%s/tags/%s", r.RepoName, r.TagName), body, nil) + err := a.Client.Repositories.UpdateTag(ctx, r.RepoName, r.TagName, params) if err != nil { return fmt.Errorf("failed to update tag '%s' in '%s': %w", r.TagName, r.RepoName, err) } return nil } -// RepoTagDeleteReq is the request for deleting a tag in a repository. type RepoTagDeleteReq struct { RepoName string TagName string @@ -245,7 +205,7 @@ func HandleRepoTagDelete(ctx context.Context, a *app.App, r RepoTagDeleteReq) er if r.TagName == "" { return fmt.Errorf("tag name is required") } - err := a.Client.Delete(ctx, fmt.Sprintf("api/v1/repositories/%s/tags/%s", r.RepoName, r.TagName), nil, nil) + err := a.Client.Repositories.DeleteTag(ctx, r.RepoName, r.TagName) if err != nil { return fmt.Errorf("failed to delete tag '%s' in '%s': %w", r.TagName, r.RepoName, err) } @@ -254,7 +214,6 @@ func HandleRepoTagDelete(ctx context.Context, a *app.App, r RepoTagDeleteReq) er // ── Fork Handler ───────────────────────────────────────────────────── -// RepoForkReq is the request for forking a public repository. type RepoForkReq struct { SourceOrg string SourceRepo string @@ -263,7 +222,7 @@ type RepoForkReq struct { TagName string } -func HandleRepoFork(ctx context.Context, a *app.App, r RepoForkReq) (*forkRepoResponse, error) { +func HandleRepoFork(ctx context.Context, a *app.App, r RepoForkReq) (*vers.ForkRepositoryResponse, error) { if r.SourceOrg == "" { return nil, fmt.Errorf("source organization is required") } @@ -273,21 +232,22 @@ func HandleRepoFork(ctx context.Context, a *app.App, r RepoForkReq) (*forkRepoRe if r.SourceTag == "" { return nil, fmt.Errorf("source tag is required") } - body := map[string]interface{}{ - "source_org": r.SourceOrg, - "source_repo": r.SourceRepo, - "source_tag": r.SourceTag, + params := vers.RepositoryForkParams{ + ForkRepositoryRequest: vers.ForkRepositoryRequestParam{ + SourceOrg: vers.F(r.SourceOrg), + SourceRepo: vers.F(r.SourceRepo), + SourceTag: vers.F(r.SourceTag), + }, } if r.RepoName != "" { - body["repo_name"] = r.RepoName + params.ForkRepositoryRequest.RepoName = vers.F(r.RepoName) } if r.TagName != "" { - body["tag_name"] = r.TagName + params.ForkRepositoryRequest.TagName = vers.F(r.TagName) } - var res forkRepoResponse - err := a.Client.Post(ctx, "api/v1/repositories/fork", body, &res) + resp, err := a.Client.Repositories.Fork(ctx, params) if err != nil { return nil, fmt.Errorf("failed to fork %s/%s:%s: %w", r.SourceOrg, r.SourceRepo, r.SourceTag, err) } - return &res, nil + return resp, nil } diff --git a/internal/presenters/repos_presenter.go b/internal/presenters/repos_presenter.go index 0bc0a05..ea1bbb3 100644 --- a/internal/presenters/repos_presenter.go +++ b/internal/presenters/repos_presenter.go @@ -4,6 +4,7 @@ import ( "fmt" "github.com/hdresearch/vers-cli/internal/app" + vers "github.com/hdresearch/vers-sdk-go" ) func RenderRepoList(_ *app.App, v RepoListView) { @@ -32,7 +33,7 @@ func RenderRepoList(_ *app.App, v RepoListView) { } } -func RenderRepoInfo(_ *app.App, r *RepoInfo) { +func RenderRepoInfo(_ *app.App, r *vers.RepositoryInfo) { vis := "private" if r.IsPublic { vis = "public" @@ -69,7 +70,7 @@ func RenderRepoTagList(_ *app.App, v RepoTagListView) { } } -func RenderRepoTagInfo(_ *app.App, t *RepoTagInfo) { +func RenderRepoTagInfo(_ *app.App, t *vers.RepoTagInfo) { fmt.Printf("Tag: %s\n", t.TagName) fmt.Printf("Tag ID: %s\n", t.TagID) fmt.Printf("Reference: %s\n", t.Reference) diff --git a/internal/presenters/repos_types.go b/internal/presenters/repos_types.go index a69f3c6..a8acdf7 100644 --- a/internal/presenters/repos_types.go +++ b/internal/presenters/repos_types.go @@ -1,30 +1,12 @@ package presenters -import "time" - -type RepoInfo struct { - RepoID string `json:"repo_id"` - Name string `json:"name"` - Description string `json:"description"` - IsPublic bool `json:"is_public"` - CreatedAt time.Time `json:"created_at"` -} - -type RepoTagInfo struct { - TagID string `json:"tag_id"` - TagName string `json:"tag_name"` - Reference string `json:"reference"` - CommitID string `json:"commit_id"` - Description string `json:"description"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` -} +import vers "github.com/hdresearch/vers-sdk-go" type RepoListView struct { - Repositories []RepoInfo + Repositories []vers.RepositoryInfo } type RepoTagListView struct { Repository string - Tags []RepoTagInfo + Tags []vers.RepoTagInfo } From 2f5a8005df4f4198807c09ad4ff7d4bbb2d54a13 Mon Sep 17 00:00:00 2001 From: Tynan Daly Date: Mon, 6 Apr 2026 14:34:02 -0700 Subject: [PATCH 4/4] test: add integration test for repo lifecycle against production MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tests the full CRUD lifecycle: create repo → list → get → create VM → commit → create tag → list tags → get tag → update tag → delete tag → delete repo Cleans up all resources (repo + VM) via t.Cleanup regardless of test outcome. Uses unique repo names to avoid collisions. --- test/integration_repo_lifecycle_test.go | 212 ++++++++++++++++++++++++ 1 file changed, 212 insertions(+) create mode 100644 test/integration_repo_lifecycle_test.go diff --git a/test/integration_repo_lifecycle_test.go b/test/integration_repo_lifecycle_test.go new file mode 100644 index 0000000..27f6272 --- /dev/null +++ b/test/integration_repo_lifecycle_test.go @@ -0,0 +1,212 @@ +package test + +import ( + "strings" + "testing" + + "github.com/hdresearch/vers-cli/test/testutil" +) + +// TestRepoLifecycle exercises the full repo CRUD lifecycle against production: +// create repo → list → get → create tag → list tags → get tag → update tag → delete tag → delete repo. +// Everything is cleaned up regardless of test outcome. +func TestRepoLifecycle(t *testing.T) { + testutil.TestEnv(t) + testutil.EnsureBuilt(t) + + repoName := testutil.UniqueAlias("repo") + + // Always clean up the repo at end, even if something fails midway. + t.Cleanup(func() { + // Best-effort delete; ignore errors (repo may already be deleted). + testutil.RunVers(t, testutil.DefaultTimeout, "repo", "delete", repoName) + }) + + // ── Create repo ────────────────────────────────────────────── + out, err := testutil.RunVers(t, testutil.DefaultTimeout, + "repo", "create", repoName, "-d", "integration test repo") + if err != nil { + t.Fatalf("repo create failed: %v\nOutput:\n%s", err, out) + } + if !strings.Contains(out, repoName) { + t.Fatalf("expected repo name in output, got:\n%s", out) + } + + // ── List repos ─────────────────────────────────────────────── + out, err = testutil.RunVers(t, testutil.DefaultTimeout, "repo", "list") + if err != nil { + t.Fatalf("repo list failed: %v\nOutput:\n%s", err, out) + } + if !strings.Contains(out, repoName) { + t.Fatalf("expected %s in list output, got:\n%s", repoName, out) + } + + // ── List repos (quiet) ─────────────────────────────────────── + out, err = testutil.RunVers(t, testutil.DefaultTimeout, "repo", "list", "-q") + if err != nil { + t.Fatalf("repo list -q failed: %v\nOutput:\n%s", err, out) + } + found := false + for _, line := range strings.Split(strings.TrimSpace(out), "\n") { + if strings.TrimSpace(line) == repoName { + found = true + break + } + } + if !found { + t.Fatalf("expected %s in quiet list output, got:\n%s", repoName, out) + } + + // ── List repos (json) ──────────────────────────────────────── + out, err = testutil.RunVers(t, testutil.DefaultTimeout, "repo", "list", "--format", "json") + if err != nil { + t.Fatalf("repo list --format json failed: %v\nOutput:\n%s", err, out) + } + if !strings.Contains(out, repoName) { + t.Fatalf("expected %s in json output, got:\n%s", repoName, out) + } + + // ── Get repo ───────────────────────────────────────────────── + out, err = testutil.RunVers(t, testutil.DefaultTimeout, "repo", "get", repoName) + if err != nil { + t.Fatalf("repo get failed: %v\nOutput:\n%s", err, out) + } + if !strings.Contains(out, repoName) { + t.Fatalf("expected %s in get output, got:\n%s", repoName, out) + } + if !strings.Contains(out, "integration test repo") { + t.Fatalf("expected description in get output, got:\n%s", out) + } + + // ── We need a commit to create a tag. Create a VM, commit it, clean up. ── + out, err = testutil.RunVers(t, testutil.DefaultTimeout, "run") + if err != nil { + t.Fatalf("vers run failed: %v\nOutput:\n%s", err, out) + } + vmID, err := testutil.ParseVMID(out) + if err != nil { + t.Fatalf("failed to parse VM ID: %v\nOutput:\n%s", err, out) + } + testutil.RegisterVMCleanup(t, vmID, true) + + out, err = testutil.RunVers(t, testutil.DefaultTimeout, "commit", "create", vmID) + if err != nil { + t.Fatalf("commit create failed: %v\nOutput:\n%s", err, out) + } + commitID := parseCommitID(t, out) + + // ── Create tag in repo ─────────────────────────────────────── + out, err = testutil.RunVers(t, testutil.DefaultTimeout, + "repo", "tag", "create", repoName, "v1", commitID, "-d", "first release") + if err != nil { + t.Fatalf("repo tag create failed: %v\nOutput:\n%s", err, out) + } + if !strings.Contains(out, repoName+":v1") { + t.Fatalf("expected reference %s:v1 in output, got:\n%s", repoName, out) + } + + // ── List tags ──────────────────────────────────────────────── + out, err = testutil.RunVers(t, testutil.DefaultTimeout, + "repo", "tag", "list", repoName) + if err != nil { + t.Fatalf("repo tag list failed: %v\nOutput:\n%s", err, out) + } + if !strings.Contains(out, "v1") { + t.Fatalf("expected v1 in tag list output, got:\n%s", out) + } + + // ── List tags (quiet) ──────────────────────────────────────── + out, err = testutil.RunVers(t, testutil.DefaultTimeout, + "repo", "tag", "list", repoName, "-q") + if err != nil { + t.Fatalf("repo tag list -q failed: %v\nOutput:\n%s", err, out) + } + if strings.TrimSpace(out) != "v1" { + t.Fatalf("expected 'v1' in quiet tag list, got:\n%s", out) + } + + // ── Get tag ────────────────────────────────────────────────── + out, err = testutil.RunVers(t, testutil.DefaultTimeout, + "repo", "tag", "get", repoName, "v1") + if err != nil { + t.Fatalf("repo tag get failed: %v\nOutput:\n%s", err, out) + } + if !strings.Contains(out, commitID) { + t.Fatalf("expected commit ID in tag get output, got:\n%s", out) + } + if !strings.Contains(out, "first release") { + t.Fatalf("expected description in tag get output, got:\n%s", out) + } + + // ── Update tag description ─────────────────────────────────── + out, err = testutil.RunVers(t, testutil.DefaultTimeout, + "repo", "tag", "update", repoName, "v1", "-d", "updated release") + if err != nil { + t.Fatalf("repo tag update failed: %v\nOutput:\n%s", err, out) + } + + // ── Delete tag ─────────────────────────────────────────────── + out, err = testutil.RunVers(t, testutil.DefaultTimeout, + "repo", "tag", "delete", repoName, "v1") + if err != nil { + t.Fatalf("repo tag delete failed: %v\nOutput:\n%s", err, out) + } + if !strings.Contains(out, "deleted") { + t.Fatalf("expected 'deleted' in output, got:\n%s", out) + } + + // ── Verify tag is gone ─────────────────────────────────────── + out, err = testutil.RunVers(t, testutil.DefaultTimeout, + "repo", "tag", "list", repoName) + if err != nil { + t.Fatalf("repo tag list after delete failed: %v\nOutput:\n%s", err, out) + } + if strings.Contains(out, "v1") && !strings.Contains(out, "No tags found") { + t.Fatalf("expected v1 to be gone from tag list, got:\n%s", out) + } + + // ── Delete repo ────────────────────────────────────────────── + out, err = testutil.RunVers(t, testutil.DefaultTimeout, "repo", "delete", repoName) + if err != nil { + t.Fatalf("repo delete failed: %v\nOutput:\n%s", err, out) + } + if !strings.Contains(out, "deleted") { + t.Fatalf("expected 'deleted' in output, got:\n%s", out) + } + + // ── Verify repo is gone ────────────────────────────────────── + out, err = testutil.RunVers(t, testutil.DefaultTimeout, "repo", "list", "-q") + if err != nil { + t.Fatalf("repo list after delete failed: %v\nOutput:\n%s", err, out) + } + if strings.Contains(out, repoName) { + t.Fatalf("expected %s to be gone from list, got:\n%s", repoName, out) + } +} + +// parseCommitID extracts a commit ID from `vers commit create` output. +// Expected format: +// +// ✓ Committed VM '' +// Commit ID: +func parseCommitID(t *testing.T, output string) string { + t.Helper() + for _, line := range strings.Split(output, "\n") { + line = strings.TrimSpace(line) + if strings.HasPrefix(line, "Commit ID:") { + parts := strings.SplitN(line, ":", 2) + if len(parts) == 2 { + id := strings.TrimSpace(parts[1]) + if id != "" { + return id + } + } + } + // Fallback: a bare UUID on its own line + if len(line) == 36 && strings.Count(line, "-") == 4 { + return line + } + } + t.Fatalf("could not parse commit ID from output:\n%s", output) + return "" +}