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/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 new file mode 100644 index 0000000..bdc522c --- /dev/null +++ b/internal/handlers/repos.go @@ -0,0 +1,253 @@ +package handlers + +import ( + "context" + "fmt" + + "github.com/hdresearch/vers-cli/internal/app" + vers "github.com/hdresearch/vers-sdk-go" +) + +// ── Repo Handlers ──────────────────────────────────────────────────── + +type RepoCreateReq struct { + Name string + Description string +} + +func HandleRepoCreate(ctx context.Context, a *app.App, r RepoCreateReq) (*vers.CreateRepositoryResponse, error) { + if r.Name == "" { + return nil, fmt.Errorf("repository name is required") + } + params := vers.RepositoryNewParams{ + CreateRepositoryRequest: vers.CreateRepositoryRequestParam{ + Name: vers.F(r.Name), + }, + } + if r.Description != "" { + params.CreateRepositoryRequest.Description = vers.F(r.Description) + } + 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 resp, nil +} + +type RepoListReq struct{} + +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 resp, nil +} + +type RepoGetReq struct { + Name string +} + +func HandleRepoGet(ctx context.Context, a *app.App, r RepoGetReq) (*vers.RepositoryInfo, error) { + if r.Name == "" { + return nil, fmt.Errorf("repository name is required") + } + 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 resp, nil +} + +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.Repositories.Delete(ctx, r.Name) + if err != nil { + return fmt.Errorf("failed to delete repository '%s': %w", r.Name, err) + } + return nil +} + +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") + } + 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) + } + return nil +} + +// ── Repo Tag Handlers ──────────────────────────────────────────────── + +type RepoTagCreateReq struct { + RepoName string + TagName string + CommitID string + Description string +} + +func HandleRepoTagCreate(ctx context.Context, a *app.App, r RepoTagCreateReq) (*vers.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") + } + params := vers.RepositoryNewTagParams{ + CreateRepoTagRequest: vers.CreateRepoTagRequestParam{ + TagName: vers.F(r.TagName), + CommitID: vers.F(r.CommitID), + }, + } + if r.Description != "" { + params.CreateRepoTagRequest.Description = vers.F(r.Description) + } + 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 resp, nil +} + +type RepoTagListReq struct { + RepoName string +} + +func HandleRepoTagList(ctx context.Context, a *app.App, r RepoTagListReq) (*vers.ListRepoTagsResponse, error) { + if r.RepoName == "" { + return nil, fmt.Errorf("repository name is required") + } + 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 resp, nil +} + +type RepoTagGetReq struct { + RepoName string + TagName string +} + +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") + } + 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 resp, nil +} + +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") + } + params := vers.RepositoryUpdateTagParams{ + UpdateRepoTagRequest: vers.UpdateRepoTagRequestParam{}, + } + if r.CommitID != "" { + params.UpdateRepoTagRequest.CommitID = vers.F(r.CommitID) + } + if r.Description != "" { + params.UpdateRepoTagRequest.Description = vers.F(r.Description) + } + 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 +} + +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.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) + } + return nil +} + +// ── Fork Handler ───────────────────────────────────────────────────── + +type RepoForkReq struct { + SourceOrg string + SourceRepo string + SourceTag string + RepoName string + TagName string +} + +func HandleRepoFork(ctx context.Context, a *app.App, r RepoForkReq) (*vers.ForkRepositoryResponse, 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") + } + params := vers.RepositoryForkParams{ + ForkRepositoryRequest: vers.ForkRepositoryRequestParam{ + SourceOrg: vers.F(r.SourceOrg), + SourceRepo: vers.F(r.SourceRepo), + SourceTag: vers.F(r.SourceTag), + }, + } + if r.RepoName != "" { + params.ForkRepositoryRequest.RepoName = vers.F(r.RepoName) + } + if r.TagName != "" { + params.ForkRepositoryRequest.TagName = vers.F(r.TagName) + } + 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 resp, nil +} diff --git a/internal/presenters/repos_presenter.go b/internal/presenters/repos_presenter.go new file mode 100644 index 0000000..ea1bbb3 --- /dev/null +++ b/internal/presenters/repos_presenter.go @@ -0,0 +1,83 @@ +package presenters + +import ( + "fmt" + + "github.com/hdresearch/vers-cli/internal/app" + vers "github.com/hdresearch/vers-sdk-go" +) + +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 *vers.RepositoryInfo) { + 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 *vers.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..a8acdf7 --- /dev/null +++ b/internal/presenters/repos_types.go @@ -0,0 +1,12 @@ +package presenters + +import vers "github.com/hdresearch/vers-sdk-go" + +type RepoListView struct { + Repositories []vers.RepositoryInfo +} + +type RepoTagListView struct { + Repository string + Tags []vers.RepoTagInfo +} 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 "" +}