diff --git a/README.md b/README.md index 3976bbf..42dc851 100644 --- a/README.md +++ b/README.md @@ -17,8 +17,8 @@ gpm know that and it'll remember. The basic workflow looks like this: +1. **Config** -- generate a config file 1. **Cache** -- load your cache to make things super quick - 1. project cache has a TTL, or you can load it manually 1. you only need to update it if there are new projects the cache is unaware of 1. **Add** -- find your project, give it any extra aliases you want 1. run `gpm project add` (or `padd` if using the provided shell defaults) @@ -40,12 +40,15 @@ The basic workflow looks like this: 1. Copy `contrib/gpm_func_omz.zsh` into your `~/.oh-my-zsh/custom/` path, or just source from your bashrc/zshrc 1. Generate config file: `gpm config gen --write` 1. You can run this any time to update settings + 1. It will only add one GitLab, so update the file for multiple 1. Run `gpm cache load` (if aliases is in-place, otherwise `gitlab-project-manager cache load`) ### Config Sample ```yaml -gitlabHost: https://gitlab.sweetwater.com -gitlabToken: +gitlabs: + - Host: https://gitlab.sweetwater.com + Token: + Name: Sweetwater GitLab logLevel: info cache: ttl: 168h @@ -54,18 +57,22 @@ cache: ``` ## TODO -- [ ] Fix initial setup requiring project path, and set https:// as default for gitlab host +- [x] Use multi-gitlab by default in config init + - [x] Update docs for multi-gitlab + - [x] Remove all references to old keys + - [x] Add auto-completion helper for --gitlab flag +- [x] Fix initial setup requiring project path, and set https:// as default for gitlab host - [ ] Fix NPE when cache is reset or project for whatever reason leaves an orphaned alias -- [ ] Add config setters and getters +- [x] Add config setters and getters - [ ] Add TTL check to cache load, and add -f / --force flag to re-build regardless - [ ] For each project loaded, check if it is the same, and do nothing - - [ ] prevents clobbering cache on a partial update - - [ ] track already loaded projects to diff after load + - [x] prevents clobbering cache on a partial update + - [x] track already loaded projects to diff after load - [ ] should prune missing after the load is complete -- [ ] Add open command - - [ ] config should exist for editor (vim, code, etc..) +- [x] Add open command + - [x] config should exist for editor (vim, code, etc..) - [ ] Update README for shell completion, aliases, usage -- [ ] Add fzf to `plist` / `gpm projects list` +- [x] Add fzf to `plist` / `gpm projects list` - [ ] Make a Makefile - [ ] Add git repo status to project go (up-to-date, pending commits, etc..) - [x] Update `gpm project show` with pterm box like `gpm project list` diff --git a/cmd/alias_add.go b/cmd/alias_add.go index 2445cb7..8a33bb2 100644 --- a/cmd/alias_add.go +++ b/cmd/alias_add.go @@ -31,13 +31,13 @@ func runAddAliasCmd(cmd *cobra.Command, args []string) { // Check by arg if len(args) > 0 { - project = fzfFindProject(args[0]) + project = fzfFindProject(&fzfProjectOpts{Ctx: cmd.Context(), Search: searchStringFromArgs(args)}) } // Collect by fzf if project == nil { var err error - project, err = fzfProject(cmd.Context()) + project, err = fzfProject(&fzfProjectOpts{Ctx: cmd.Context()}) if err != nil || project == nil { plog.Fatal("No project to alias, nothing to do", plog.Args("error", err)) } @@ -62,7 +62,7 @@ func addNewAliases(projectID int) { if a == "" { continue } - if err := cache.AddAlias(a, project.ID); err != nil { + if err := cache.AddAlias(a, project.ID, project.Remote); err != nil { plog.Debug("Skipping alias add", plog.Args( "error", err, "alias", a, @@ -98,5 +98,8 @@ func promptAliasesForProject(p *gitlab.Project) []string { func init() { aliasCmd.AddCommand(aliasAddCmd) aliasAddCmd.PersistentFlags().Int("projectID", 0, "Specify a project by ID") + + aliasAddCmd.RegisterFlagCompletionFunc("projectID", validProjectIdFunc) + viper.BindPFlag("alias.add.projectID", aliasAddCmd.Flag("projectID")) } diff --git a/cmd/alias_delete.go b/cmd/alias_delete.go index 6998955..4b46dcb 100644 --- a/cmd/alias_delete.go +++ b/cmd/alias_delete.go @@ -22,10 +22,16 @@ func runDeleteAliasCmd(cmd *cobra.Command, args []string) { var project *gitlab.Project var err error + fzfOpts := &fzfProjectOpts{ + Ctx: cmd.Context(), + MustHaveAlias: true, + } + if len(args) > 0 { - project = fzfFindProject(args[0]) + fzfOpts.Search = searchStringFromArgs(args) + project = fzfFindProject(fzfOpts) } else { - project, err = fzfProject(cmd.Context(), true) + project, err = fzfProject(fzfOpts) } if project == nil || err != nil { @@ -73,5 +79,8 @@ func runDeleteAliasCmd(cmd *cobra.Command, args []string) { func init() { aliasCmd.AddCommand(aliasDeleteCmd) aliasDeleteCmd.PersistentFlags().Int("projectID", 0, "Specify a project by ID") + + aliasDeleteCmd.RegisterFlagCompletionFunc("projectID", validProjectIdFunc) + viper.BindPFlag("alias.delete.projectID", aliasDeleteCmd.Flag("projectID")) } diff --git a/cmd/cache_dump.go b/cmd/cache_dump.go index 37f2ef8..3130f4c 100644 --- a/cmd/cache_dump.go +++ b/cmd/cache_dump.go @@ -15,7 +15,7 @@ var dumpCmd = &cobra.Command{ PostRun: postCacheCmd, Run: func(cmd *cobra.Command, args []string) { if conf.Dump.Full { - fmt.Println(cache.DumpString(true)) + fmt.Println(cache.DumpString(true, searchStringFromArgs(args))) } else { plog.Info(cache.String()) } diff --git a/cmd/config_generate.go b/cmd/config_generate.go index 6bf6b68..6e1e492 100644 --- a/cmd/config_generate.go +++ b/cmd/config_generate.go @@ -69,11 +69,36 @@ func writeConfigFile(c *config.Config, path string) { } func promptConfigSettings(c *config.Config) *config.Config { + var gitlabConfig *config.GitlabConfig + // Just pick the first if we have one, considering this + // is meant to be a first-time use tool + if len(c.Gitlabs) > 0 { + gitlabConfig = &c.Gitlabs[0] + } else { + gitlabConfig = &config.DefaultConfig.Gitlabs[0] + c.Gitlabs = append(c.Gitlabs, *gitlabConfig) + } + if host, err := pterm.DefaultInteractiveTextInput. - WithDefaultValue(c.GitlabHost). + WithDefaultValue(gitlabConfig.Host). WithDefaultText("Enter gitlab URL"). Show(); err == nil { - c.GitlabHost = host + gitlabConfig.Host = host + } + + if name, err := pterm.DefaultInteractiveTextInput. + WithDefaultValue(gitlabConfig.Name). + WithDefaultText("Enter gitlab name (e.g. My Private GitLab)"). + Show(); err == nil { + gitlabConfig.Name = name + } + + if token, err := pterm.DefaultInteractiveTextInput. + WithMask("*"). + WithDefaultValue(gitlabConfig.Token). + WithDefaultText("Enter gitlab Token"). + Show(); err == nil { + gitlabConfig.Token = token } if pPath, err := pterm.DefaultInteractiveTextInput. @@ -93,14 +118,6 @@ func promptConfigSettings(c *config.Config) *config.Config { } } - if token, err := pterm.DefaultInteractiveTextInput. - WithMask("*"). - WithDefaultValue(c.GitlabToken). - WithDefaultText("Enter gitlab Token"). - Show(); err == nil { - c.GitlabToken = token - } - if dirMode, err := pterm.DefaultInteractiveConfirm. WithDefaultValue(true). WithDefaultText("Open project directories instead of main files (yes for vscode)?"). diff --git a/cmd/project.go b/cmd/project.go index 7f484f1..7ec8265 100644 --- a/cmd/project.go +++ b/cmd/project.go @@ -2,6 +2,7 @@ package cmd import ( "github.com/spf13/cobra" + "github.com/spf13/viper" "gitlab.sweetwater.com/it/devops/tools/gitlab-project-manager/internal/gitlab" "gitlab.sweetwater.com/it/devops/tools/gitlab-project-manager/internal/projects" ) @@ -19,7 +20,13 @@ var projectCmd = &cobra.Command{ } func getProject(args []string) *gitlab.Project { - project := fzfFindProject(searchStringFromArgs(args)) + gitlabs := viper.GetStringSlice("project.gitlabs") + fzfOpts := &fzfProjectOpts{ + Ctx: rootCmd.Context(), + Search: searchStringFromArgs(args), + Gitlabs: gitlabs, + } + project := fzfFindProject(fzfOpts) if project == nil { plog.Fatal("Failed to find a project, nothing to do") @@ -50,6 +57,9 @@ func postProjectCmd(cmd *cobra.Command, args []string) { func init() { rootCmd.AddCommand(projectCmd) + projectCmd.PersistentFlags().StringArray("gitlab", []string{}, "Specify gitlab remote, provide flag multiple times") + projectCmd.RegisterFlagCompletionFunc("gitlab", validGitlabRemotesFunc) + viper.BindPFlag("project.gitlabs", projectCmd.Flag("gitlab")) } func mustHaveProjects(cmd *cobra.Command, args []string) { diff --git a/cmd/project_go.go b/cmd/project_go.go index a494e72..aab8174 100644 --- a/cmd/project_go.go +++ b/cmd/project_go.go @@ -6,6 +6,7 @@ import ( "os/exec" "github.com/spf13/cobra" + "github.com/spf13/viper" ) var projectGoCmd = &cobra.Command{ @@ -20,7 +21,14 @@ var projectGoCmd = &cobra.Command{ } func projectGoCmdRun(cmd *cobra.Command, args []string) { - project := fzfSearchProjectAliases(searchStringFromArgs(args)) + gitlabs := viper.GetStringSlice("project.gitlabs") + fzfOpts := &fzfProjectOpts{ + Ctx: cmd.Context(), + Search: searchStringFromArgs(args), + MustHaveAlias: true, + Gitlabs: gitlabs, + } + project := fzfSearchProjectAliases(fzfOpts) if project == nil { plog.Fatal("No project selected, nowhere to go") diff --git a/cmd/project_list.go b/cmd/project_list.go index 31748bf..ecb0334 100644 --- a/cmd/project_list.go +++ b/cmd/project_list.go @@ -16,7 +16,8 @@ var projectListCmd = &cobra.Command{ } func projectListCmdRun(cmd *cobra.Command, args []string) { - fmt.Println(cache.DumpString(viper.GetBool("project.list.all"))) + gitlabs := viper.GetStringSlice("project.gitlabs") + fmt.Println(cache.DumpString(viper.GetBool("project.list.all"), searchStringFromArgs(args), gitlabs...)) } func init() { diff --git a/cmd/project_open.go b/cmd/project_open.go index 6bbb7f0..77d1ed0 100644 --- a/cmd/project_open.go +++ b/cmd/project_open.go @@ -51,8 +51,13 @@ func projectOpenCmdRun(cmd *cobra.Command, args []string) { plog.Fatal("No usable editor found") } - // Identify search terms - project := fzfCwdOrSearchProjectAliases(searchStringFromArgs(args)) + gitlabs := viper.GetStringSlice("project.gitlabs") + fzfOpts := &fzfProjectOpts{ + Ctx: cmd.Context(), + Search: searchStringFromArgs(args), + Gitlabs: gitlabs, + } + project := fzfCwdOrSearchProjectAliases(fzfOpts) if project == nil { plog.Fatal("No project to open, nothing to do") } diff --git a/cmd/project_run.go b/cmd/project_run.go index 978dffc..e78f728 100644 --- a/cmd/project_run.go +++ b/cmd/project_run.go @@ -6,6 +6,7 @@ import ( "os/exec" "github.com/spf13/cobra" + "github.com/spf13/viper" ) var projectRunCmd = &cobra.Command{ @@ -17,7 +18,13 @@ var projectRunCmd = &cobra.Command{ } func projectRunCmdRun(cmd *cobra.Command, args []string) { - project := fzfCwdOrSearchProjectAliases(searchStringFromArgs(args)) + gitlabs := viper.GetStringSlice("project.gitlabs") + fzfOpts := &fzfProjectOpts{ + Ctx: cmd.Context(), + Search: searchStringFromArgs(args), + Gitlabs: gitlabs, + } + project := fzfCwdOrSearchProjectAliases(fzfOpts) if project == nil { plog.Fatal("No project selected, nothing to open") } diff --git a/cmd/project_show.go b/cmd/project_show.go index 1aac2a1..df8f288 100644 --- a/cmd/project_show.go +++ b/cmd/project_show.go @@ -20,14 +20,16 @@ var projectShowCmd = &cobra.Command{ } func projectShowCmdRun(cmd *cobra.Command, args []string) { - var searchString string - if len(args) > 0 { - searchString = args[0] - } - var project *gitlab.Project var inCwd bool + gitlabs := viper.GetStringSlice("project.gitlabs") + fzfOpts := &fzfProjectOpts{ + Ctx: cmd.Context(), + Search: searchStringFromArgs(args), + Gitlabs: gitlabs, + } + // Try to find project from current directory if viper.GetBool("project.show.current") { var err error @@ -46,13 +48,13 @@ func projectShowCmdRun(cmd *cobra.Command, args []string) { // Otherwise find from the given search string if project == nil { - project = fzfFindProject(searchString) + project = fzfFindProject(fzfOpts) } // Do a full fuzzy find if all else fails if project == nil { var err error - project, err = fzfProject(cmd.Context()) + project, err = fzfProject(fzfOpts) if err != nil || project == nil { plog.Fatal("Failed to find project, nothing to show", plog.Args( "error", err, diff --git a/cmd/root.go b/cmd/root.go index 416433e..9a39e3f 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -49,15 +49,13 @@ func init() { rootCmd.PersistentFlags().String("config", "", "config file (default is "+defConfigPath+")") - rootCmd.PersistentFlags().String("gitlabHost", defGitlabHost, - "GitLab Hostname (e.g. gitlab.com)") - rootCmd.PersistentFlags().String("gitlabToken", "", - "GitLab Tokenname (e.g. gitlab.com)") rootCmd.PersistentFlags().String("projectPath", "", "Sets a path for local clones of projects") rootCmd.PersistentFlags().String("logLevel", defLogLevel, "Default log level -- info, warn, error, debug") + rootCmd.RegisterFlagCompletionFunc("logLevel", validLogLevelsFunc) + viper.BindPFlags(rootCmd.PersistentFlags()) } diff --git a/cmd/util_completion.go b/cmd/util_completion.go index cf8aeac..ddffe73 100644 --- a/cmd/util_completion.go +++ b/cmd/util_completion.go @@ -1,6 +1,12 @@ package cmd -import "github.com/spf13/cobra" +import ( + "strconv" + "strings" + + "github.com/spf13/cobra" + "golang.org/x/exp/slices" +) func validProjectsFunc(cmd *cobra.Command, args []string, toComplete string) ( []string, cobra.ShellCompDirective) { @@ -20,3 +26,39 @@ func validProjectsOrAliasesFunc(cmd *cobra.Command, args []string, toComplete st aliasStrings, _ := validProjectsFunc(cmd, args, toComplete) return append(projectStrings, aliasStrings...), cobra.ShellCompDirectiveDefault } + +func validGitlabRemotesFunc(cmd *cobra.Command, args []string, toComplete string) ( + []string, cobra.ShellCompDirective) { + remotes := make([]string, 0, len(conf.Gitlabs)) + for _, remote := range conf.Gitlabs { + if strings.HasPrefix(remote.Host, toComplete) { + remotes = append(remotes, remote.Host) + } + } + return remotes, cobra.ShellCompDirectiveNoFileComp +} + +func validLogLevelsFunc(cmd *cobra.Command, args []string, toComplete string) ( + []string, cobra.ShellCompDirective) { + levels := []string{"info", "warn", "error", "debug"} + matchingLevels := make([]string, 0, len(levels)) + for _, level := range levels { + if strings.HasPrefix(level, toComplete) { + matchingLevels = append(matchingLevels, level) + } + } + return matchingLevels, cobra.ShellCompDirectiveNoFileComp +} + +func validProjectIdFunc(cmd *cobra.Command, args []string, toComplete string) ( + []string, cobra.ShellCompDirective) { + initProjectCache(cmd, args) + matchingIds := make([]string, 0, len(cache.Projects)) + for _, p := range cache.Projects { + idString := strconv.FormatInt(int64(p.ID), 10) + if strings.HasPrefix(idString, toComplete) { + matchingIds = append(matchingIds, idString) + } + } + return slices.Clip(matchingIds), cobra.ShellCompDirectiveNoFileComp +} diff --git a/cmd/util_fzf.go b/cmd/util_fzf.go index 6627daa..73640df 100644 --- a/cmd/util_fzf.go +++ b/cmd/util_fzf.go @@ -6,18 +6,26 @@ import ( fzf "github.com/ktr0731/go-fuzzyfinder" "gitlab.sweetwater.com/it/devops/tools/gitlab-project-manager/internal/gitlab" "gitlab.sweetwater.com/it/devops/tools/gitlab-project-manager/internal/projects" + "golang.org/x/exp/slices" ) +type fzfProjectOpts struct { + Ctx context.Context + Search string + MustHaveAlias bool + Gitlabs []string +} + // This will try to find a project by alias if a search term // is given, otherwise will fuzzy find by project -func fzfFindProject(searchString string) *gitlab.Project { +func fzfFindProject(opts *fzfProjectOpts) *gitlab.Project { var project *gitlab.Project - if searchString != "" { - project = fzfSearchProjectAliases(searchString) + if opts.Search != "" { + project = fzfSearchProjectAliases(opts) } else { var err error - project, err = fzfProject(rootCmd.Context()) + project, err = fzfProject(opts) if project == nil || err != nil { return nil } @@ -29,32 +37,32 @@ func fzfFindProject(searchString string) *gitlab.Project { // If . is given as a project, will open project from the // current working directory. Otherwise, will attempt to fuzzy-find // a project given a search term if provided -func fzfCwdOrSearchProjectAliases(searchString string) *gitlab.Project { +func fzfCwdOrSearchProjectAliases(opts *fzfProjectOpts) *gitlab.Project { var project *gitlab.Project - if searchString == "." { + if opts.Search == "." { project, _ = cache.GetProjectFromCwd() } else { - project = fzfSearchProjectAliases(searchString) + project = fzfSearchProjectAliases(opts) } return project } // This will fuzzy search only aliases, preferring an exact // match if one is given -func fzfSearchProjectAliases(searchString string) *gitlab.Project { +func fzfSearchProjectAliases(opts *fzfProjectOpts) *gitlab.Project { var project *gitlab.Project var alias *projects.ProjectAlias - if alias = cache.GetAliasByName(searchString); alias != nil { + if alias = cache.GetAliasByName(opts.Search, opts.Gitlabs...); alias != nil { project = cache.GetProjectByAlias(alias) plog.Info("Perfect alias match... flawless") } else { // Get fuzzy if we don't have an exact match - aliases := cache.FuzzyFindAlias(searchString) + aliases := cache.FuzzyFindAlias(opts.Search) if len(aliases) > 1 { // If multiple aliases were found, switch over to project // by alias mode with merging // alias = fzfAliasFromAliases(rootCmd.Context(), aliases) - project, _ = fzfProjectFromAliases(rootCmd.Context(), aliases) + project, _ = fzfProjectFromAliases(opts, aliases) } else if len(aliases) == 1 { alias = aliases[0] project = cache.GetProjectByAlias(alias) @@ -67,14 +75,14 @@ func fzfSearchProjectAliases(searchString string) *gitlab.Project { // a single one. Replaced by fzfProjectFromAliases in fzfSearchProjectAliases // as merging is preferred, but can be used if it's ever desirable to // return a single alias from all aliases -func fzfAliasFromAliases(ctx context.Context, aliases []*projects.ProjectAlias) *projects.ProjectAlias { +func fzfAliasFromAliases(opts *fzfProjectOpts, aliases []*projects.ProjectAlias) *projects.ProjectAlias { var alias *projects.ProjectAlias i, err := fzf.Find( aliases, func(i int) string { return aliases[i].Alias + " -> " + cache.GetProjectByAlias(aliases[i]).PathWithNamespace }, - fzf.WithContext(ctx), + fzf.WithContext(opts.Ctx), fzf.WithHeader("Choose an Alias"), ) if err != nil { @@ -87,13 +95,13 @@ func fzfAliasFromAliases(ctx context.Context, aliases []*projects.ProjectAlias) // Given a list of aliases, merge them together and use the resulting // list of projects to return a project -func fzfProjectFromAliases(ctx context.Context, aliases []*projects.ProjectAlias) ( +func fzfProjectFromAliases(opts *fzfProjectOpts, aliases []*projects.ProjectAlias) ( *gitlab.Project, error) { mergedProjects := projectsFromAliases(aliases) if len(mergedProjects) == 1 { return mergedProjects[0], nil } - return fzfProjectFromProjects(ctx, mergedProjects) + return fzfProjectFromProjects(opts, mergedProjects) } func projectsFromAliases(aliases []*projects.ProjectAlias) []*gitlab.Project { @@ -103,7 +111,7 @@ ALIASES: for _, a := range aliases { for _, p := range projects { // Already have it - if a.ProjectID == p.ID { + if a.ProjectID == p.ID && a.Remote == p.Remote { continue ALIASES } } @@ -113,20 +121,22 @@ ALIASES: return projects } -// If a bool=true is provided, will only allow selection of projects +// If opts.MustHaveAlias, will only allow selection of projects // that have at least one alias defined -func fzfProject(ctx context.Context, mustHaveAlias ...bool) (*gitlab.Project, error) { +func fzfProject(opts *fzfProjectOpts) (*gitlab.Project, error) { var searchableProjects []*gitlab.Project - if len(mustHaveAlias) == 1 && mustHaveAlias[0] { + if opts.MustHaveAlias { searchableProjects = cache.GetProjectsWithAliases() } else { searchableProjects = cache.Projects } - return fzfProjectFromProjects(ctx, searchableProjects) + // Filter out unwanted gitlabs if provided + searchableProjects = filterProjectsWithGitlabs(searchableProjects, opts.Gitlabs...) + return fzfProjectFromProjects(opts, searchableProjects) } // Takes a list of projects and performs a fuzzyfind -func fzfProjectFromProjects(ctx context.Context, projects []*gitlab.Project) ( +func fzfProjectFromProjects(opts *fzfProjectOpts, projects []*gitlab.Project) ( *gitlab.Project, error) { i, err := fzf.Find(projects, func(i int) string { @@ -138,7 +148,7 @@ func fzfProjectFromProjects(ctx context.Context, projects []*gitlab.Project) ( return cache.ProjectString(projects[i]) }, ), - fzf.WithContext(ctx), + fzf.WithContext(opts.Ctx), fzf.WithHeader("Fuzzy find yourself a project"), ) if err != nil || i < 0 { @@ -152,6 +162,20 @@ func fzfPreviewWindow(i, w, h int) string { return cache.ProjectString(p) } +func filterProjectsWithGitlabs(projects []*gitlab.Project, gitlabs ...string) []*gitlab.Project { + filteredProjects := make([]*gitlab.Project, 0, len(projects)) + if len(gitlabs) > 0 { + for _, p := range projects { + if slices.Contains(gitlabs, p.Remote) { + filteredProjects = append(filteredProjects, p) + } + } + } else { + filteredProjects = projects + } + return filteredProjects +} + // Nearly useless function that simply returns either an // empty string, or a string from the first arg if one is provided func searchStringFromArgs(args []string) string { diff --git a/cmd/util_init.go b/cmd/util_init.go index ba01ab3..5b0d95a 100644 --- a/cmd/util_init.go +++ b/cmd/util_init.go @@ -22,9 +22,40 @@ func initProjectCache(cmd *cobra.Command, args []string) { plog.Debug("Running pre-run for cacheCmd") conf.Cache.File = conf.ProjectPath + "/.cache.yaml" - gitlabClient, err := gitlab.NewGitlabClient(cmd.Context(), conf.GitlabHost, conf.GitlabToken) + // Backwards-compatible support for singular instance + opts := make([]*gitlab.ClientOpts, 0) + + if conf.GitlabHost != "" { + opts = append(opts, &gitlab.ClientOpts{ + Ctx: cmd.Context(), + Host: conf.GitlabHost, // deprecated, switch to gitlabs + Token: conf.GitlabToken, // deprecated, switch to gitlabs + Name: conf.GitlabHost, // not originally supported, use the new gitlabs field + }) + } + + // If defined, load additional instances + for _, g := range conf.Gitlabs { + opts = append(opts, &gitlab.ClientOpts{ + Ctx: cmd.Context(), + Name: g.Name, + Host: g.Host, + Token: g.Token, + }) + } + + // We need at least one GitLab + if len(opts) < 1 { + plog.Error("At least one GitLab must be configured. Add to .gitlabs in your config file") + os.Exit(1) + } + + // Load all gitlab configs into clients + var gitlabs *gitlab.Clients + var err error + gitlabs, err = gitlab.NewGitlabClients(opts) if err != nil { - plog.Error("Failed to create GitLab client", plog.Args("error", err)) + plog.Error("Failed to create GitLab clients", plog.Args("error", err)) os.Exit(1) } @@ -33,7 +64,7 @@ func initProjectCache(cmd *cobra.Command, args []string) { Path: conf.Cache.File, TTL: conf.Cache.Ttl, Logger: plog, - Gitlab: gitlabClient, + Gitlabs: gitlabs, Config: &conf, } if cache, err = projects.NewProjectCache(cacheOpts); err != nil { @@ -45,6 +76,8 @@ func initProjectCache(cmd *cobra.Command, args []string) { plog.Error("Cache load failed", plog.Args("error", err)) os.Exit(1) } + + plog.Debug("Gitlab Clients", plog.Args("gitlabs", cacheOpts.Gitlabs)) } func postProjectCache(cmd *cobra.Command, args []string) { diff --git a/internal/config/config.go b/internal/config/config.go index 19a6ff3..3e74658 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -3,15 +3,24 @@ package config import "time" type Config struct { - Editor editorConfig `yaml:"editor" json:"editor"` - GitlabHost string `yaml:"gitlabHost" json:"gitlabHost"` - GitlabToken string `yaml:"gitlabToken,omitempty" json:"gitlabToken,omitempty"` - LogLevel string `yaml:"logLevel" json:"logLevel" enum:"info,warn,debug,error"` - ProjectPath string `yaml:"projectPath" json:"projectPath"` - Cache cacheConfig `yaml:"cache" json:"cache"` + // Named keys above maintained for backwards compatibility + // Ideally only Gitlabs is used + GitlabHost string `yaml:"gitlabHost,omitempty" json:"gitlabHost,omitempty"` + GitlabToken string `yaml:"gitlabToken,omitempty" json:"gitlabToken,omitempty"` + Gitlabs []GitlabConfig `yaml:"gitlabs" json:"gitlabs"` + LogLevel string `yaml:"logLevel" json:"logLevel" enum:"info,warn,debug,error"` + ProjectPath string `yaml:"projectPath" json:"projectPath"` + Cache cacheConfig `yaml:"cache" json:"cache"` Dump struct { - Full bool - } + Full bool `yaml:"full" json:"full"` + } `yaml:"dump" json:"dump"` + Editor editorConfig `yaml:"editor" json:"editor"` +} + +type GitlabConfig struct { + Host string `yaml:"host" json:"host"` + Name string `yaml:"name" json:"name"` + Token string `yaml:"token" json:"token"` } type editorConfig struct { @@ -38,10 +47,13 @@ type cacheConfig struct { } var DefaultConfig = Config{ - GitlabHost: "https://gitlab.com", - GitlabToken: "yourtokenhere", LogLevel: "warn", ProjectPath: "~/work/projects", + Gitlabs: []GitlabConfig{{ + Host: "https://gitlab.com", + Token: "yourtokenhere", + Name: "GitLab", + }}, Cache: cacheConfig{ Ttl: 168 * time.Hour, Load: loadConfig{ diff --git a/internal/gitlab/gitlab.go b/internal/gitlab/gitlab.go index 343b091..c5cbcf1 100644 --- a/internal/gitlab/gitlab.go +++ b/internal/gitlab/gitlab.go @@ -1,10 +1,8 @@ package gitlab import ( - "context" "fmt" "strings" - "sync" "time" "github.com/go-git/go-git/v5" @@ -21,11 +19,6 @@ const ( projectsPerGoroutine = 200 ) -type Client struct { - Ctx context.Context - gitlab *gitlab.Client -} - type Project struct { ID int Description string @@ -39,6 +32,8 @@ type Project struct { AvatarURL string LastActivityAt time.Time Readme string + Remote string + Owner string Languages *ProjectLanguages gitRepo *git.Repository } @@ -58,22 +53,16 @@ type User struct { AvatarURL string } -type ProgressInfo struct { - ProgressChan chan Progress - ProjectsChan chan []*Project - ErrorChan chan error - DoneChan chan interface{} -} - -type Progress struct { - Page int - Pages int - Projects int - TotalProjects int +func (c *Client) Api() *gitlab.Client { + return c.apiClient } func (p *Project) String() string { - return fmt.Sprintf("%s (%s)", p.Path, p.PathWithNamespace) + var projectString string + if p != nil { + projectString = fmt.Sprintf("%s (%s)", p.Path, p.PathWithNamespace) + } + return projectString } func (p *Project) GetLanguage() *ProjectLanguage { @@ -105,117 +94,7 @@ func (p *Project) GetRepo() *git.Repository { return p.gitRepo } -func (p *Project) GetGitInfo() string { - repo := p.GetRepo() - if repo == nil { - return "No Repo" - } - - var str string - str += "\n" + pterm.LightRed("Project: ") + pterm.Bold.Sprint(p.Name) + "\n" - - head, _ := repo.Head() - branch := head.Name().String()[11:] - b, _ := repo.Branch(branch) - if b != nil { - str += pterm.LightCyan("Branch: ") + pterm.Bold.Sprint(b.Name) + "\n" - } else { - str += pterm.LightCyan("NEW Branch: ") + pterm.Bold.Sprint(branch) + "\n" - } - - commit, _ := repo.CommitObject(head.Hash()) - str += "\n" + commit.String() - - str += pterm.LightMagenta("GitLab: ") + pterm.Bold.Sprint(p.HTTPURLToRepo) + "\n" - - if remotes, _ := repo.Remotes(); len(remotes) > 0 { - str += pterm.LightBlue("Remote: ") + pterm.Bold.Sprint(remotes[0].Config().URLs[0]) - } - - return pterm.DefaultBox. - WithLeftPadding(5).WithRightPadding(5). - WithBoxStyle(&pterm.Style{pterm.FgLightBlue}). - WithTitle(pterm.Bold.Sprint(pterm.LightGreen("Project Git Status"))). - Sprint(str) -} - -// Given there may be thousands of projects, this will return -// channels that stream progress info and then finally the full -// list of projects on separate channels. If ownerOnly=true, only -// projects for which you are an owner will be loaded -func (c *Client) StreamProjects(ownerOnly bool) *ProgressInfo { - pi := &ProgressInfo{ - ProgressChan: make(chan Progress), - ProjectsChan: make(chan []*Project), - ErrorChan: make(chan error), - DoneChan: make(chan interface{}), - } - - go c.streamProjects(pi, ownerOnly) - - return pi -} - -func (c *Client) streamProjects(pi *ProgressInfo, ownerOnly bool) { - defer close(pi.ProgressChan) - defer close(pi.ProjectsChan) - - listOpts := &gitlab.ListProjectsOptions{ - ListOptions: gitlab.ListOptions{ - PerPage: projectsPerPage, - Page: 1, - }, - Archived: gitlab.Ptr[bool](false), - Owned: gitlab.Ptr[bool](ownerOnly), - } - - // Get total number of projects - projectsTotal := c.getTotalProjects(listOpts) - numGoroutines := projectsTotal / projectsPerGoroutine - - wg := sync.WaitGroup{} - startPage := 1 - for i := 1; i <= numGoroutines+1; i++ { - wg.Add(1) - endPage := startPage + (projectsPerGoroutine / projectsPerPage) - go func(startPage int, endPage int) { - defer wg.Done() - opts := *listOpts - opts.Page = startPage - for { - projects, resp, err := c.ListProjects(&opts) - - if err != nil { - pi.ErrorChan <- err - break - } - - pi.ProjectsChan <- projects - pi.ProgressChan <- Progress{ - Page: resp.CurrentPage, - Pages: resp.TotalPages, - Projects: len(projects), - TotalProjects: resp.TotalItems, - } - - // We're done when we have it all or our context is done - // or we've hit our total pages - if c.Ctx.Err() != nil || resp.NextPage == 0 { - break - } else if opts.Page == endPage { - break - } - - opts.Page = resp.NextPage - } - }(startPage, endPage) - startPage = endPage + 1 - } - wg.Wait() - pi.DoneChan <- nil -} - -func (c *Client) getTotalProjects(opts *gitlab.ListProjectsOptions) int { +func (c *Client) GetTotalProjects(opts *gitlab.ListProjectsOptions) int { reqOpts := *opts reqOpts.ListOptions = gitlab.ListOptions{ Page: 1, @@ -223,7 +102,7 @@ func (c *Client) getTotalProjects(opts *gitlab.ListProjectsOptions) int { } var projects int - if _, r, e := c.gitlab.Projects.ListProjects(opts, gitlab.WithContext(c.Ctx)); e == nil { + if _, r, e := c.apiClient.Projects.ListProjects(opts, gitlab.WithContext(c.Ctx)); e == nil { projects = r.TotalItems } @@ -235,7 +114,7 @@ func (c *Client) getTotalProjects(opts *gitlab.ListProjectsOptions) int { func (c *Client) ListProjects(opts *gitlab.ListProjectsOptions) ( []*Project, *gitlab.Response, error) { pList := make([]*Project, 0) - projects, resp, err := c.gitlab.Projects.ListProjects( + projects, resp, err := c.apiClient.Projects.ListProjects( opts, gitlab.WithContext(c.Ctx), ) @@ -245,35 +124,10 @@ func (c *Client) ListProjects(opts *gitlab.ListProjectsOptions) ( return pList, resp, err } -func (c *Client) handleProjects(projects []*gitlab.Project) []*Project { - // Opportunity to perform any filtering or additional lookups - // on a per-project basis - pList := make([]*Project, 0, len(projects)) - for _, project := range projects { - p := &Project{ - ID: project.ID, - Description: project.Description, - SSHURLToRepo: project.SSHURLToRepo, - HTTPURLToRepo: project.HTTPURLToRepo, - WebURL: project.WebURL, - Name: project.Name, - NameWithNamespace: project.NameWithNamespace, - Path: project.Path, - PathWithNamespace: project.PathWithNamespace, - AvatarURL: project.AvatarURL, - LastActivityAt: *project.LastActivityAt, - Readme: project.ReadmeURL, - Languages: c.GetProjectLanguages(project), - } - pList = append(pList, p) - } - return pList -} - // A nil return indicates an API error or GitLab doesn't know what // language the project uses. func (c *Client) GetProjectLanguages(project *gitlab.Project) *ProjectLanguages { - l, _, e := c.gitlab.Projects.GetProjectLanguages(project.ID, gitlab.WithContext(c.Ctx)) + l, _, e := c.apiClient.Projects.GetProjectLanguages(project.ID, gitlab.WithContext(c.Ctx)) if e != nil { pterm.Error.Printfln("Failed requesting project languages: %s", e.Error()) return nil @@ -293,15 +147,3 @@ func (c *Client) GetProjectLanguages(project *gitlab.Project) *ProjectLanguages return &pLangs } - -func NewGitlabClient(ctx context.Context, host, token string) (*Client, error) { - client, err := gitlab.NewClient(token, gitlab.WithBaseURL(host)) - if err != nil { - return nil, err - } - gitlabClient := &Client{ - Ctx: ctx, - gitlab: client, - } - return gitlabClient, nil -} diff --git a/internal/gitlab/gitlab_client.go b/internal/gitlab/gitlab_client.go new file mode 100644 index 0000000..7ed729c --- /dev/null +++ b/internal/gitlab/gitlab_client.go @@ -0,0 +1,83 @@ +package gitlab + +import ( + "context" + "errors" + "fmt" + + "github.com/xanzy/go-gitlab" + "gitlab.sweetwater.com/it/devops/tools/gitlab-project-manager/internal/config" +) + +type Client struct { + Ctx context.Context + Config *config.GitlabConfig + apiClient *gitlab.Client +} + +type Clients []*Client + +type ClientOpts struct { + Ctx context.Context + Name string + Host string + Token string +} + +func (c *Clients) AddClients(gitlabClient ...*Client) error { + var err error + for _, client := range gitlabClient { + if c.GetClientByHost(client.Config.Host) != nil { + err = errors.Join(err, fmt.Errorf("Client with host %s already exists", client.Config.Host)) + } else { + *c = append(*c, client) + } + } + return err +} + +func (c *Clients) GetClientByHost(host string) *Client { + for _, client := range *c { + if client.Config.Host == host { + return client + } + } + return nil +} + +func NewCLients() *Clients { + var clients Clients + clients = make([]*Client, 0) + return &clients +} + +func NewGitlabClients(clientOpts []*ClientOpts) (*Clients, error) { + var err error + clients := NewCLients() + for _, opts := range clientOpts { + gitlabClient, e := NewGitlabClient(opts) + if e != nil { + err = errors.Join(err, e) + continue + } + err = errors.Join(err, clients.AddClients(gitlabClient)) + } + return clients, err +} + +func NewGitlabClient(opts *ClientOpts) (*Client, error) { + client, err := gitlab.NewClient(opts.Token, gitlab.WithBaseURL(opts.Host)) + if err != nil { + return nil, err + } + gitlabClient := &Client{ + Ctx: opts.Ctx, + Config: &config.GitlabConfig{ + Name: opts.Name, + Host: opts.Host, + Token: opts.Token, + }, + apiClient: client, + } + return gitlabClient, nil +} diff --git a/internal/gitlab/gitlab_git.go b/internal/gitlab/gitlab_git.go new file mode 100644 index 0000000..370fa20 --- /dev/null +++ b/internal/gitlab/gitlab_git.go @@ -0,0 +1,37 @@ +package gitlab + +import "github.com/pterm/pterm" + +func (p *Project) GetGitInfo() string { + repo := p.GetRepo() + if repo == nil { + return "No Repo" + } + + var str string + str += "\n" + pterm.LightRed("Project: ") + pterm.Bold.Sprint(p.Name) + "\n" + + head, _ := repo.Head() + branch := head.Name().String()[11:] + b, _ := repo.Branch(branch) + if b != nil { + str += pterm.LightCyan("Branch: ") + pterm.Bold.Sprint(b.Name) + "\n" + } else { + str += pterm.LightCyan("NEW Branch: ") + pterm.Bold.Sprint(branch) + "\n" + } + + commit, _ := repo.CommitObject(head.Hash()) + str += "\n" + commit.String() + + str += pterm.LightMagenta("GitLab: ") + pterm.Bold.Sprint(p.HTTPURLToRepo) + "\n" + + if remotes, _ := repo.Remotes(); len(remotes) > 0 { + str += pterm.LightBlue("Remote: ") + pterm.Bold.Sprint(remotes[0].Config().URLs[0]) + } + + return pterm.DefaultBox. + WithLeftPadding(5).WithRightPadding(5). + WithBoxStyle(&pterm.Style{pterm.FgLightBlue}). + WithTitle(pterm.Bold.Sprint(pterm.LightGreen("Project Git Status"))). + Sprint(str) +} diff --git a/internal/gitlab/gitlab_load.go b/internal/gitlab/gitlab_load.go new file mode 100644 index 0000000..d6d7ef4 --- /dev/null +++ b/internal/gitlab/gitlab_load.go @@ -0,0 +1,131 @@ +package gitlab + +import ( + "sync" + + "github.com/xanzy/go-gitlab" +) + +type ProgressInfo struct { + ProgressChan chan Progress + ProjectsChan chan []*Project + ErrorChan chan error + DoneChan chan interface{} + NumProjects int +} + +type Progress struct { + Page int + Pages int + Projects int + TotalProjects int +} + +var DefaultListOpts = &gitlab.ListProjectsOptions{ + ListOptions: gitlab.ListOptions{ + PerPage: projectsPerPage, + Page: 1, + }, + Archived: gitlab.Ptr[bool](false), +} + +// Given there may be thousands of projects, this will return +// channels that stream progress info and then finally the full +// list of projects on separate channels. If ownerOnly=true, only +// projects for which you are an owner will be loaded +func (c *Client) StreamProjects(ownerOnly bool, projects int) *ProgressInfo { + pi := &ProgressInfo{ + ProgressChan: make(chan Progress), + ProjectsChan: make(chan []*Project), + ErrorChan: make(chan error), + DoneChan: make(chan interface{}), + NumProjects: projects, + } + + go c.streamProjects(pi, ownerOnly) + + return pi +} + +func (c *Client) streamProjects(pi *ProgressInfo, ownerOnly bool) { + defer close(pi.ProgressChan) + defer close(pi.ProjectsChan) + + listOpts := *DefaultListOpts + listOpts.Owned = gitlab.Ptr[bool](ownerOnly) + + // Get total number of projects + numGoroutines := pi.NumProjects / projectsPerGoroutine + + wg := sync.WaitGroup{} + startPage := 1 + for i := 1; i <= numGoroutines+1; i++ { + wg.Add(1) + endPage := startPage + (projectsPerGoroutine / projectsPerPage) + go func(startPage int, endPage int) { + defer wg.Done() + opts := listOpts + opts.Page = startPage + for { + projects, resp, err := c.ListProjects(&opts) + + if err != nil { + pi.ErrorChan <- err + break + } + + pi.ProjectsChan <- projects + pi.ProgressChan <- Progress{ + Page: resp.CurrentPage, + Pages: resp.TotalPages, + Projects: len(projects), + TotalProjects: resp.TotalItems, + } + + // We're done when we have it all or our context is done + // or we've hit our total pages + if c.Ctx.Err() != nil || resp.NextPage == 0 { + break + } else if opts.Page == endPage { + break + } + + opts.Page = resp.NextPage + } + }(startPage, endPage) + startPage = endPage + 1 + } + wg.Wait() + pi.DoneChan <- nil +} + +func (c *Client) handleProjects(projects []*gitlab.Project) []*Project { + // Opportunity to perform any filtering or additional lookups + // on a per-project basis + pList := make([]*Project, 0, len(projects)) + for _, project := range projects { + var owner string + if project.Owner != nil { + owner = project.Owner.Email + } + p := &Project{ + ID: project.ID, + Description: project.Description, + SSHURLToRepo: project.SSHURLToRepo, + HTTPURLToRepo: project.HTTPURLToRepo, + WebURL: project.WebURL, + Name: project.Name, + NameWithNamespace: project.NameWithNamespace, + Path: project.Path, + PathWithNamespace: project.PathWithNamespace, + Remote: c.Config.Host, + Owner: owner, + AvatarURL: project.AvatarURL, + LastActivityAt: *project.LastActivityAt, + Readme: project.ReadmeURL, + Languages: c.GetProjectLanguages(project), + } + pList = append(pList, p) + } + return pList +} diff --git a/internal/projects/cache.go b/internal/projects/cache.go index ebc8cdd..285d119 100644 --- a/internal/projects/cache.go +++ b/internal/projects/cache.go @@ -3,7 +3,6 @@ package projects import ( "fmt" "os" - "strings" "sync" "time" @@ -19,12 +18,13 @@ type Cache struct { Updated time.Time config *config.Config readFromFile bool - lock *sync.Mutex + lock *sync.Mutex // Lock the entire cache + contentLock *sync.Mutex // Lock projects / aliases ttl time.Duration file string log *pterm.Logger - gitlab *gitlab.Client path string + gitlabs *gitlab.Clients } type CacheOpts struct { @@ -32,7 +32,7 @@ type CacheOpts struct { ProjectsPath string TTL time.Duration Logger *pterm.Logger - Gitlab *gitlab.Client + Gitlabs *gitlab.Clients Config *config.Config } @@ -94,6 +94,10 @@ func (c *Cache) write() { func (c *Cache) Write() { c.lock.Lock() defer c.lock.Unlock() + c.log.Debug("Saving cache to disk", c.log.Args( + "projects", len(c.Projects), + "aliases", len(c.Aliases), + )) c.write() } @@ -136,7 +140,11 @@ func (c *Cache) Clear(clearAliases bool) { func (c *Cache) refresh() { c.log.Info("Loading project cache, this may take a while\n") defer c.setUpdated() - c.LoadProjects() + // Fix any dangling aliases + // For backwards-compatibility only + c.setAliasRemotes() + // Retrieve and add/update projects + c.LoadGitlabs() } // Iterates through all GitLab projects the user has access to, updating @@ -148,39 +156,16 @@ func (c *Cache) Refresh() { } func (c *Cache) String() string { - return fmt.Sprintf("Cache Updated %s: Projects %d, Aliases %d", + cacheString := fmt.Sprintf("Cache Updated %s: Projects %d, Aliases %d\nRemotes %d:", c.Updated.String(), len(c.Projects), - len(c.Aliases)) -} - -// This command will only dump projects that have -// been cloned locally. Setting all to true will list all projects -func (c *Cache) DumpString(all bool) string { - str := strings.Builder{} - var term string - if all { - term = pterm.Bold.Sprint("Projects") - } else { - term = pterm.Bold.Sprint("Local Projects") + len(c.Aliases), + len(*c.gitlabs), + ) + for _, r := range *c.gitlabs { + cacheString += " " + r.Config.Host } - str.WriteString(c.String() + "\n\n" + term + ":\n") - for _, project := range c.Projects { - if !all && !c.IsProjectCloned(project) { - continue - } - str.WriteString(" - " + pterm.FgLightBlue.Sprint(project.Name) + " (") - str.WriteString(project.PathWithNamespace + ")\n") - aliases := c.GetProjectAliases(project) - if len(aliases) > 0 { - str.WriteString(pterm.FgLightGreen.Sprint(" aliases:")) - for _, a := range aliases { - str.WriteString(" [" + pterm.FgCyan.Sprint(a.Alias) + "]") - } - str.WriteRune('\n') - } - } - return str.String() + return cacheString } func (c *Cache) setUpdated() { @@ -207,16 +192,25 @@ func NewProjectCache(opts *CacheOpts) (*Cache, error) { err = createProjectCache(opts.Path) } + // Combine old-and-new gitlabs + var gitlabs *gitlab.Clients + if opts.Gitlabs != nil { + gitlabs = opts.Gitlabs + } else { + gitlabs = gitlab.NewCLients() + } + cache := &Cache{ - Projects: make([]*gitlab.Project, 0), - Aliases: make([]*ProjectAlias, 0), - config: opts.Config, - file: opts.Path, - ttl: opts.TTL, - lock: &sync.Mutex{}, - log: opts.Logger, - gitlab: opts.Gitlab, - path: opts.ProjectsPath, + Projects: make([]*gitlab.Project, 0), + Aliases: make([]*ProjectAlias, 0), + config: opts.Config, + file: opts.Path, + ttl: opts.TTL, + lock: &sync.Mutex{}, + contentLock: &sync.Mutex{}, + log: opts.Logger, + gitlabs: gitlabs, + path: opts.ProjectsPath, } return cache, err diff --git a/internal/projects/cache_aliases.go b/internal/projects/cache_aliases.go index 33eb41b..349ce84 100644 --- a/internal/projects/cache_aliases.go +++ b/internal/projects/cache_aliases.go @@ -2,8 +2,6 @@ package projects import ( "errors" - "fmt" - "strings" "gitlab.sweetwater.com/it/devops/tools/gitlab-project-manager/internal/gitlab" "golang.org/x/exp/slices" @@ -23,7 +21,7 @@ func (c *Cache) DeleteAlias(alias *ProjectAlias) { c.deleteAlias(alias) } -func (c *Cache) addAlias(alias string, projectID int) error { +func (c *Cache) addAlias(alias string, projectID int, remote string) error { if c.GetAliasByName(alias) != nil { return errors.New("Failed to add alias, already exists") } @@ -32,15 +30,16 @@ func (c *Cache) addAlias(alias string, projectID int) error { &ProjectAlias{ Alias: alias, ProjectID: projectID, + Remote: remote, }) return nil } -func (c *Cache) AddAlias(alias string, projectID int) error { +func (c *Cache) AddAlias(alias string, projectID int, remote string) error { c.lock.Lock() defer c.lock.Unlock() - return c.addAlias(alias, projectID) + return c.addAlias(alias, projectID, remote) } func (c *Cache) GetProjectsWithAliases() []*gitlab.Project { @@ -55,20 +54,26 @@ func (c *Cache) GetProjectsWithAliases() []*gitlab.Project { return projectList } -func (c *Cache) GetProjectAliasStrings(project *gitlab.Project) []string { - aliases := c.GetProjectAliases(project) - strings := make([]string, len(aliases)) - for i, a := range c.GetProjectAliases(project) { - strings[i] = a.Alias +// This method only exists if a cache was built +// before multi-remote support. Upon the first load +// with multi-remotes, this will be run first to update +// any missing alias remotes +func (c *Cache) setAliasRemotes() { + for _, alias := range c.Aliases { + if alias.Remote == "" { + c.setAliasRemote(alias) + } } - return strings } -func (c *Cache) GetProjectStringWithAliases(project *gitlab.Project) string { - aliases := c.GetProjectAliasStrings(project) - return fmt.Sprintf("%s (%s) -> %s", - project.Name, - strings.Join(aliases, ", "), - project.PathWithNamespace, - ) +func (c *Cache) setAliasRemote(alias *ProjectAlias) { + project := c.GetProjectByID(alias.ProjectID) + if project != nil { + alias.Remote = project.Remote + c.log.Debug("Fixed missing alias remote", c.log.Args( + "alias", alias.Alias, + "projectID", alias.ProjectID, + "remote", alias.Remote, + )) + } } diff --git a/internal/projects/cache_load.go b/internal/projects/cache_load.go new file mode 100644 index 0000000..57c2a3e --- /dev/null +++ b/internal/projects/cache_load.go @@ -0,0 +1,65 @@ +package projects + +import ( + "fmt" + "sync" + + "github.com/pterm/pterm" + "gitlab.sweetwater.com/it/devops/tools/gitlab-project-manager/internal/gitlab" +) + +func (c *Cache) LoadGitlabs() { + wg := &sync.WaitGroup{} + writer := pterm.DefaultMultiPrinter + + for _, gl := range *c.gitlabs { + c.log.Info("Loading projects for remote", c.log.Args( + "host", gl.Config.Host, + "name", gl.Config.Name, + )) + + opts := *gitlab.DefaultListOpts + opts.Owned = &c.config.Cache.Load.OwnerOnly + projects := gl.GetTotalProjects(&opts) + + // Prepare progressbar + pBar, _ := pterm.DefaultProgressbar. + WithShowPercentage(true). + WithTotal(projects). + WithWriter(writer.NewWriter()). + WithMaxWidth(100). + Start(gl.Config.Host) + + wg.Add(1) + go c.LoadGitlab(gl, wg, pBar, projects) + } + + fmt.Println("") + writer.Start() + + wg.Wait() + + writer.Stop() + fmt.Println("") +} + +func (c *Cache) LoadGitlab(client *gitlab.Client, wg *sync.WaitGroup, pBar *pterm.ProgressbarPrinter, projects int) { + defer wg.Done() + progressInfo := client.StreamProjects(c.config.Cache.Load.OwnerOnly, projects) + + for { + select { + case p := <-progressInfo.ProgressChan: + pBar.Add(p.Projects) + case p := <-progressInfo.ProjectsChan: + c.AddProjects(p...) + case e := <-progressInfo.ErrorChan: + c.log.Error("Fetch GitLab projects error", c.log.Args("error", e, "gitlab", client.Config.Name)) + case <-client.Ctx.Done(): + c.log.Warn("LoadProjects cancelled", c.log.Args("reason", client.Ctx.Err())) + return + case <-progressInfo.DoneChan: + return + } + } +} diff --git a/internal/projects/cache_projects.go b/internal/projects/cache_projects.go new file mode 100644 index 0000000..3e15875 --- /dev/null +++ b/internal/projects/cache_projects.go @@ -0,0 +1,76 @@ +package projects + +import ( + "strings" + + "github.com/pterm/pterm" + "gitlab.sweetwater.com/it/devops/tools/gitlab-project-manager/internal/gitlab" + "golang.org/x/exp/slices" +) + +func (c *Cache) AddProjects(projects ...*gitlab.Project) { + c.contentLock.Lock() + defer c.contentLock.Unlock() + for _, p := range projects { + var existing *gitlab.Project + sameID := c.GetProjectsByID(p.ID) + + // If there is only one by ID, we don't + // have to worry about matching remotes. + // If there are more than one, either match + // remotes or update the remote if it was never + // set due to being build from old code + // New caches should never have empty remotes. + if len(sameID) == 1 { + existing = sameID[0] + } else if len(sameID) > 1 { + for _, pr := range sameID { + if pr.Remote == p.Remote || pr.Remote == "" { + existing = pr + break + } + } + } + + // Add a new one, or update if changed + if existing == nil { + c.Projects = append(c.Projects, p) + } else if *existing != *p { + *existing = *p + } + } +} + +// This command will only dump projects that have +// been cloned locally. Setting all to true will list all projects +func (c *Cache) DumpString(all bool, search string, gitlabs ...string) string { + str := strings.Builder{} + var term string + if all { + term = pterm.Bold.Sprint("Projects") + } else { + term = pterm.Bold.Sprint("Local Projects") + } + str.WriteString(c.String() + "\n\n" + term + ":\n") + projects := c.FuzzyFindProjects(search) + for _, project := range projects { + if !all && !c.IsProjectCloned(project) { + continue + } else if len(gitlabs) > 0 && !slices.Contains(gitlabs, project.Remote) { + continue + } + str.WriteString(" - " + pterm.FgLightBlue.Sprint(project.Name) + " (") + str.WriteString(project.PathWithNamespace + ")\n") + aliases := c.GetProjectAliases(project) + if len(aliases) > 0 { + str.WriteString(pterm.FgLightGreen.Sprint(" aliases:")) + for _, a := range aliases { + str.WriteString(" [" + pterm.FgCyan.Sprint(a.Alias) + "]") + } + str.WriteRune('\n') + } + str.WriteString(pterm.FgLightMagenta.Sprint(" remote: ")) + str.WriteString(project.Remote + "\n") + } + return str.String() +} diff --git a/internal/projects/fuzz.go b/internal/projects/fuzz.go index 49b538e..33c41f2 100644 --- a/internal/projects/fuzz.go +++ b/internal/projects/fuzz.go @@ -4,6 +4,7 @@ import ( "strings" "github.com/lithammer/fuzzysearch/fuzzy" + "gitlab.sweetwater.com/it/devops/tools/gitlab-project-manager/internal/gitlab" ) // Performs a fuzzy find on the input string, returning the closest @@ -35,3 +36,22 @@ func (c *Cache) FuzzyFindAlias(name string) []*ProjectAlias { } return aliases } + +// Returns all matching projects by fuzzy find term +// Matches NameWithNamespace and Aliases +func (c *Cache) FuzzyFindProjects(search string) []*gitlab.Project { + projects := make([]*gitlab.Project, 0, len(c.Projects)) + for _, p := range c.Projects { + if fuzzy.MatchFold(search, p.NameWithNamespace) { + projects = append(projects, p) + continue + } + for _, a := range c.GetProjectAliases(p) { + if fuzzy.MatchFold(search, a.Alias) { + projects = append(projects, p) + continue + } + } + } + return projects +} diff --git a/internal/projects/projects.go b/internal/projects/projects.go index 274dd18..3b56a56 100644 --- a/internal/projects/projects.go +++ b/internal/projects/projects.go @@ -1,46 +1,13 @@ package projects import ( - "bytes" - "fmt" "strings" - "text/tabwriter" "github.com/pterm/pterm" "gitlab.sweetwater.com/it/devops/tools/gitlab-project-manager/internal/gitlab" + "golang.org/x/exp/slices" ) -type ProjectAlias struct { - Alias string - ProjectID int -} - -func ProjectAliasesString(aliases []*ProjectAlias) string { - var str string - for _, a := range aliases { - str += "[" + pterm.LightCyan(a.Alias) + "] " - } - return strings.Trim(str, " ") -} - -func (c *Cache) AliasesByProjectString() string { - var str bytes.Buffer - - w := new(tabwriter.Writer) - w.Init(&str, 10, 0, 0, ' ', tabwriter.AlignRight) - - for _, p := range c.GetProjectsWithAliases() { - var pa string - pa += pterm.LightBlue("- ") - pa += fmt.Sprint(pterm.Bold.Sprint(p.String()) + " \t ") - pa += fmt.Sprint(ProjectAliasesString(c.GetProjectAliases(p))) - fmt.Fprintln(w, pa) - } - - w.Flush() - return str.String() -} - func (c *Cache) ProjectString(p *gitlab.Project) string { info := strings.Builder{} @@ -72,26 +39,7 @@ func (c *Cache) ProjectStrings(prefix string) []string { projects = append(projects, p.NameWithNamespace) } } - return projects -} - -func (c *Cache) AliasStrings(prefix string) []string { - aliases := make([]string, 0, len(c.Aliases)) - for _, a := range c.Aliases { - if strings.HasPrefix(a.Alias, prefix) { - aliases = append(aliases, a.Alias) - } - } - return aliases -} - -func (c *Cache) GetAliasByName(name string) *ProjectAlias { - for _, a := range c.Aliases { - if name == a.Alias { - return a - } - } - return nil + return slices.Clip(projects) } func (c *Cache) GetProjectByPath(path string) *gitlab.Project { @@ -103,6 +51,15 @@ func (c *Cache) GetProjectByPath(path string) *gitlab.Project { return nil } +func (c *Cache) GetProjectByRemoteAndId(remote string, id int) *gitlab.Project { + for _, p := range c.Projects { + if p.ID == id && p.Remote == remote { + return p + } + } + return nil +} + func (c *Cache) GetProjectByID(id int) *gitlab.Project { for _, p := range c.Projects { if p.ID == id { @@ -112,63 +69,15 @@ func (c *Cache) GetProjectByID(id int) *gitlab.Project { return nil } -func (c *Cache) GetProjectByAlias(alias *ProjectAlias) *gitlab.Project { - if alias == nil { - return nil - } +// Plural form of GetProjectByID +// Since multiple remotes may have the same project ID, +// this will return all matching +func (c *Cache) GetProjectsByID(id int) []*gitlab.Project { + projects := make([]*gitlab.Project, 0) for _, p := range c.Projects { - if p.ID == alias.ProjectID { - return p - } - } - return nil -} - -func (c *Cache) GetProjectAliases(project *gitlab.Project) []*ProjectAlias { - aliases := make([]*ProjectAlias, 0) - for _, alias := range c.Aliases { - if alias.ProjectID == project.ID { - aliases = append(aliases, alias) - } - } - return aliases -} - -func (c *Cache) LoadProjects() { - progressInfo := c.gitlab.StreamProjects(c.config.Cache.Load.OwnerOnly) - c.Projects = make([]*gitlab.Project, 0) - - pBar := pterm.DefaultProgressbar. - WithShowPercentage(true). - WithTotal(-1). - WithTitle("Listing GitLab Projects"). - WithMaxWidth(100) - - defer pBar.Stop() - - for { - select { - case p := <-progressInfo.ProgressChan: - if pBar.Total == -1 { - pBar = pBar.WithTotal(p.TotalProjects) - pBar, _ = pBar.Start() - } - // This sucks, has to be a better way, and why is the logger incompatible - // with the progressbar? - pterm.Debug.Println(fmt.Sprintf("Update received: %#v", p)) - pBar.Add(p.Projects) - case p := <-progressInfo.ProjectsChan: - c.Projects = append(c.Projects, p...) - case e := <-progressInfo.ErrorChan: - c.log.Error("Fetch GitLab projects error", c.log.Args("error", e)) - case <-c.gitlab.Ctx.Done(): - c.log.Warn("LoadProjects cancelled", c.log.Args("reason", c.gitlab.Ctx.Err())) - return - case <-progressInfo.DoneChan: - // pBar.Add(pBar.Total - curProjects) - fmt.Println("") - c.log.Info("Project load complete") - return + if p.ID == id { + projects = append(projects, p) } } + return projects } diff --git a/internal/projects/projects_alias.go b/internal/projects/projects_alias.go new file mode 100644 index 0000000..59ba441 --- /dev/null +++ b/internal/projects/projects_alias.go @@ -0,0 +1,106 @@ +package projects + +import ( + "bytes" + "fmt" + "strings" + "text/tabwriter" + + "github.com/pterm/pterm" + "gitlab.sweetwater.com/it/devops/tools/gitlab-project-manager/internal/gitlab" + "golang.org/x/exp/slices" +) + +type ProjectAlias struct { + Alias string + ProjectID int + Remote string +} + +func (c *Cache) GetProjectAliasStrings(project *gitlab.Project) []string { + aliases := c.GetProjectAliases(project) + strings := make([]string, len(aliases)) + for i, a := range c.GetProjectAliases(project) { + strings[i] = a.Alias + } + return strings +} + +func (c *Cache) GetProjectStringWithAliases(project *gitlab.Project) string { + aliases := c.GetProjectAliasStrings(project) + return fmt.Sprintf("%s (%s) -> %s", + project.Name, + strings.Join(aliases, ", "), + project.PathWithNamespace, + ) +} + +func ProjectAliasesString(aliases []*ProjectAlias) string { + var str string + for _, a := range aliases { + str += "[" + pterm.LightCyan(a.Alias) + "] " + } + return strings.Trim(str, " ") +} + +func (c *Cache) AliasesByProjectString() string { + var str bytes.Buffer + + w := new(tabwriter.Writer) + w.Init(&str, 10, 0, 0, ' ', tabwriter.AlignRight) + + for _, p := range c.GetProjectsWithAliases() { + var pa string + pa += pterm.LightBlue("- ") + pa += fmt.Sprint(pterm.Bold.Sprint(p.String()) + " \t ") + pa += fmt.Sprint(ProjectAliasesString(c.GetProjectAliases(p))) + fmt.Fprintln(w, pa) + } + + w.Flush() + return str.String() +} + +func (c *Cache) AliasStrings(prefix string) []string { + aliases := make([]string, 0, len(c.Aliases)) + for _, a := range c.Aliases { + if strings.HasPrefix(a.Alias, prefix) { + aliases = append(aliases, a.Alias) + } + } + return aliases +} + +func (c *Cache) GetAliasByName(name string, gitlabs ...string) *ProjectAlias { + for _, a := range c.Aliases { + if len(gitlabs) > 0 && !slices.Contains(gitlabs, a.Remote) { + continue + } + if name == a.Alias { + return a + } + } + return nil +} + +func (c *Cache) GetProjectByAlias(alias *ProjectAlias) *gitlab.Project { + if alias == nil { + return nil + } + for _, p := range c.Projects { + if p.ID == alias.ProjectID && p.Remote == alias.Remote { + return p + } + } + return nil +} + +func (c *Cache) GetProjectAliases(project *gitlab.Project) []*ProjectAlias { + aliases := make([]*ProjectAlias, 0) + for _, alias := range c.Aliases { + if alias.ProjectID == project.ID { + aliases = append(aliases, alias) + } + } + return aliases +}