Add multi-remote support for GitLab (#1)

Co-authored-by: Ryan D McGuire <ryand_mcguire@sweetwater.com>
Reviewed-on: 50W/git-project-manager#1
This commit is contained in:
Ryan McGuire 2024-01-14 15:33:15 +00:00
parent 415290de20
commit b944af140a
27 changed files with 868 additions and 422 deletions

View File

@ -17,8 +17,8 @@ gpm know that and it'll remember.
The basic workflow looks like this: The basic workflow looks like this:
1. **Config** -- generate a config file
1. **Cache** -- load your cache to make things super quick 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. 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. **Add** -- find your project, give it any extra aliases you want
1. run `gpm project add` (or `padd` if using the provided shell defaults) 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. 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. Generate config file: `gpm config gen --write`
1. You can run this any time to update settings 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`) 1. Run `gpm cache load` (if aliases is in-place, otherwise `gitlab-project-manager cache load`)
### Config Sample ### Config Sample
```yaml ```yaml
gitlabHost: https://gitlab.sweetwater.com gitlabs:
gitlabToken: <your token here> - Host: https://gitlab.sweetwater.com
Token: <your token here>
Name: Sweetwater GitLab
logLevel: info logLevel: info
cache: cache:
ttl: 168h ttl: 168h
@ -54,18 +57,22 @@ cache:
``` ```
## TODO ## 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 - [ ] 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 - [ ] 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 - [ ] For each project loaded, check if it is the same, and do nothing
- [ ] prevents clobbering cache on a partial update - [x] prevents clobbering cache on a partial update
- [ ] track already loaded projects to diff after load - [x] track already loaded projects to diff after load
- [ ] should prune missing after the load is complete - [ ] should prune missing after the load is complete
- [ ] Add open command - [x] Add open command
- [ ] config should exist for editor (vim, code, etc..) - [x] config should exist for editor (vim, code, etc..)
- [ ] Update README for shell completion, aliases, usage - [ ] 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 - [ ] Make a Makefile
- [ ] Add git repo status to project go (up-to-date, pending commits, etc..) - [ ] 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` - [x] Update `gpm project show` with pterm box like `gpm project list`

View File

@ -31,13 +31,13 @@ func runAddAliasCmd(cmd *cobra.Command, args []string) {
// Check by arg // Check by arg
if len(args) > 0 { if len(args) > 0 {
project = fzfFindProject(args[0]) project = fzfFindProject(&fzfProjectOpts{Ctx: cmd.Context(), Search: searchStringFromArgs(args)})
} }
// Collect by fzf // Collect by fzf
if project == nil { if project == nil {
var err error var err error
project, err = fzfProject(cmd.Context()) project, err = fzfProject(&fzfProjectOpts{Ctx: cmd.Context()})
if err != nil || project == nil { if err != nil || project == nil {
plog.Fatal("No project to alias, nothing to do", plog.Args("error", err)) plog.Fatal("No project to alias, nothing to do", plog.Args("error", err))
} }
@ -62,7 +62,7 @@ func addNewAliases(projectID int) {
if a == "" { if a == "" {
continue 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( plog.Debug("Skipping alias add", plog.Args(
"error", err, "error", err,
"alias", a, "alias", a,
@ -98,5 +98,8 @@ func promptAliasesForProject(p *gitlab.Project) []string {
func init() { func init() {
aliasCmd.AddCommand(aliasAddCmd) aliasCmd.AddCommand(aliasAddCmd)
aliasAddCmd.PersistentFlags().Int("projectID", 0, "Specify a project by ID") aliasAddCmd.PersistentFlags().Int("projectID", 0, "Specify a project by ID")
aliasAddCmd.RegisterFlagCompletionFunc("projectID", validProjectIdFunc)
viper.BindPFlag("alias.add.projectID", aliasAddCmd.Flag("projectID")) viper.BindPFlag("alias.add.projectID", aliasAddCmd.Flag("projectID"))
} }

View File

@ -22,10 +22,16 @@ func runDeleteAliasCmd(cmd *cobra.Command, args []string) {
var project *gitlab.Project var project *gitlab.Project
var err error var err error
fzfOpts := &fzfProjectOpts{
Ctx: cmd.Context(),
MustHaveAlias: true,
}
if len(args) > 0 { if len(args) > 0 {
project = fzfFindProject(args[0]) fzfOpts.Search = searchStringFromArgs(args)
project = fzfFindProject(fzfOpts)
} else { } else {
project, err = fzfProject(cmd.Context(), true) project, err = fzfProject(fzfOpts)
} }
if project == nil || err != nil { if project == nil || err != nil {
@ -73,5 +79,8 @@ func runDeleteAliasCmd(cmd *cobra.Command, args []string) {
func init() { func init() {
aliasCmd.AddCommand(aliasDeleteCmd) aliasCmd.AddCommand(aliasDeleteCmd)
aliasDeleteCmd.PersistentFlags().Int("projectID", 0, "Specify a project by ID") aliasDeleteCmd.PersistentFlags().Int("projectID", 0, "Specify a project by ID")
aliasDeleteCmd.RegisterFlagCompletionFunc("projectID", validProjectIdFunc)
viper.BindPFlag("alias.delete.projectID", aliasDeleteCmd.Flag("projectID")) viper.BindPFlag("alias.delete.projectID", aliasDeleteCmd.Flag("projectID"))
} }

View File

@ -15,7 +15,7 @@ var dumpCmd = &cobra.Command{
PostRun: postCacheCmd, PostRun: postCacheCmd,
Run: func(cmd *cobra.Command, args []string) { Run: func(cmd *cobra.Command, args []string) {
if conf.Dump.Full { if conf.Dump.Full {
fmt.Println(cache.DumpString(true)) fmt.Println(cache.DumpString(true, searchStringFromArgs(args)))
} else { } else {
plog.Info(cache.String()) plog.Info(cache.String())
} }

View File

@ -69,11 +69,36 @@ func writeConfigFile(c *config.Config, path string) {
} }
func promptConfigSettings(c *config.Config) *config.Config { 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. if host, err := pterm.DefaultInteractiveTextInput.
WithDefaultValue(c.GitlabHost). WithDefaultValue(gitlabConfig.Host).
WithDefaultText("Enter gitlab URL"). WithDefaultText("Enter gitlab URL").
Show(); err == nil { 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. 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. if dirMode, err := pterm.DefaultInteractiveConfirm.
WithDefaultValue(true). WithDefaultValue(true).
WithDefaultText("Open project directories instead of main files (yes for vscode)?"). WithDefaultText("Open project directories instead of main files (yes for vscode)?").

View File

@ -2,6 +2,7 @@ package cmd
import ( import (
"github.com/spf13/cobra" "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/gitlab"
"gitlab.sweetwater.com/it/devops/tools/gitlab-project-manager/internal/projects" "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 { 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 { if project == nil {
plog.Fatal("Failed to find a project, nothing to do") plog.Fatal("Failed to find a project, nothing to do")
@ -50,6 +57,9 @@ func postProjectCmd(cmd *cobra.Command, args []string) {
func init() { func init() {
rootCmd.AddCommand(projectCmd) 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) { func mustHaveProjects(cmd *cobra.Command, args []string) {

View File

@ -6,6 +6,7 @@ import (
"os/exec" "os/exec"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/spf13/viper"
) )
var projectGoCmd = &cobra.Command{ var projectGoCmd = &cobra.Command{
@ -20,7 +21,14 @@ var projectGoCmd = &cobra.Command{
} }
func projectGoCmdRun(cmd *cobra.Command, args []string) { 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 { if project == nil {
plog.Fatal("No project selected, nowhere to go") plog.Fatal("No project selected, nowhere to go")

View File

@ -16,7 +16,8 @@ var projectListCmd = &cobra.Command{
} }
func projectListCmdRun(cmd *cobra.Command, args []string) { 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() { func init() {

View File

@ -51,8 +51,13 @@ func projectOpenCmdRun(cmd *cobra.Command, args []string) {
plog.Fatal("No usable editor found") plog.Fatal("No usable editor found")
} }
// Identify search terms gitlabs := viper.GetStringSlice("project.gitlabs")
project := fzfCwdOrSearchProjectAliases(searchStringFromArgs(args)) fzfOpts := &fzfProjectOpts{
Ctx: cmd.Context(),
Search: searchStringFromArgs(args),
Gitlabs: gitlabs,
}
project := fzfCwdOrSearchProjectAliases(fzfOpts)
if project == nil { if project == nil {
plog.Fatal("No project to open, nothing to do") plog.Fatal("No project to open, nothing to do")
} }

View File

@ -6,6 +6,7 @@ import (
"os/exec" "os/exec"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/spf13/viper"
) )
var projectRunCmd = &cobra.Command{ var projectRunCmd = &cobra.Command{
@ -17,7 +18,13 @@ var projectRunCmd = &cobra.Command{
} }
func projectRunCmdRun(cmd *cobra.Command, args []string) { 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 { if project == nil {
plog.Fatal("No project selected, nothing to open") plog.Fatal("No project selected, nothing to open")
} }

View File

@ -20,14 +20,16 @@ var projectShowCmd = &cobra.Command{
} }
func projectShowCmdRun(cmd *cobra.Command, args []string) { func projectShowCmdRun(cmd *cobra.Command, args []string) {
var searchString string
if len(args) > 0 {
searchString = args[0]
}
var project *gitlab.Project var project *gitlab.Project
var inCwd bool 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 // Try to find project from current directory
if viper.GetBool("project.show.current") { if viper.GetBool("project.show.current") {
var err error var err error
@ -46,13 +48,13 @@ func projectShowCmdRun(cmd *cobra.Command, args []string) {
// Otherwise find from the given search string // Otherwise find from the given search string
if project == nil { if project == nil {
project = fzfFindProject(searchString) project = fzfFindProject(fzfOpts)
} }
// Do a full fuzzy find if all else fails // Do a full fuzzy find if all else fails
if project == nil { if project == nil {
var err error var err error
project, err = fzfProject(cmd.Context()) project, err = fzfProject(fzfOpts)
if err != nil || project == nil { if err != nil || project == nil {
plog.Fatal("Failed to find project, nothing to show", plog.Args( plog.Fatal("Failed to find project, nothing to show", plog.Args(
"error", err, "error", err,

View File

@ -49,15 +49,13 @@ func init() {
rootCmd.PersistentFlags().String("config", "", rootCmd.PersistentFlags().String("config", "",
"config file (default is "+defConfigPath+")") "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", "", rootCmd.PersistentFlags().String("projectPath", "",
"Sets a path for local clones of projects") "Sets a path for local clones of projects")
rootCmd.PersistentFlags().String("logLevel", defLogLevel, rootCmd.PersistentFlags().String("logLevel", defLogLevel,
"Default log level -- info, warn, error, debug") "Default log level -- info, warn, error, debug")
rootCmd.RegisterFlagCompletionFunc("logLevel", validLogLevelsFunc)
viper.BindPFlags(rootCmd.PersistentFlags()) viper.BindPFlags(rootCmd.PersistentFlags())
} }

View File

@ -1,6 +1,12 @@
package cmd 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) ( func validProjectsFunc(cmd *cobra.Command, args []string, toComplete string) (
[]string, cobra.ShellCompDirective) { []string, cobra.ShellCompDirective) {
@ -20,3 +26,39 @@ func validProjectsOrAliasesFunc(cmd *cobra.Command, args []string, toComplete st
aliasStrings, _ := validProjectsFunc(cmd, args, toComplete) aliasStrings, _ := validProjectsFunc(cmd, args, toComplete)
return append(projectStrings, aliasStrings...), cobra.ShellCompDirectiveDefault 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
}

View File

@ -6,18 +6,26 @@ import (
fzf "github.com/ktr0731/go-fuzzyfinder" 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/gitlab"
"gitlab.sweetwater.com/it/devops/tools/gitlab-project-manager/internal/projects" "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 // This will try to find a project by alias if a search term
// is given, otherwise will fuzzy find by project // is given, otherwise will fuzzy find by project
func fzfFindProject(searchString string) *gitlab.Project { func fzfFindProject(opts *fzfProjectOpts) *gitlab.Project {
var project *gitlab.Project var project *gitlab.Project
if searchString != "" { if opts.Search != "" {
project = fzfSearchProjectAliases(searchString) project = fzfSearchProjectAliases(opts)
} else { } else {
var err error var err error
project, err = fzfProject(rootCmd.Context()) project, err = fzfProject(opts)
if project == nil || err != nil { if project == nil || err != nil {
return nil return nil
} }
@ -29,32 +37,32 @@ func fzfFindProject(searchString string) *gitlab.Project {
// If . is given as a project, will open project from the // If . is given as a project, will open project from the
// current working directory. Otherwise, will attempt to fuzzy-find // current working directory. Otherwise, will attempt to fuzzy-find
// a project given a search term if provided // a project given a search term if provided
func fzfCwdOrSearchProjectAliases(searchString string) *gitlab.Project { func fzfCwdOrSearchProjectAliases(opts *fzfProjectOpts) *gitlab.Project {
var project *gitlab.Project var project *gitlab.Project
if searchString == "." { if opts.Search == "." {
project, _ = cache.GetProjectFromCwd() project, _ = cache.GetProjectFromCwd()
} else { } else {
project = fzfSearchProjectAliases(searchString) project = fzfSearchProjectAliases(opts)
} }
return project return project
} }
// This will fuzzy search only aliases, preferring an exact // This will fuzzy search only aliases, preferring an exact
// match if one is given // match if one is given
func fzfSearchProjectAliases(searchString string) *gitlab.Project { func fzfSearchProjectAliases(opts *fzfProjectOpts) *gitlab.Project {
var project *gitlab.Project var project *gitlab.Project
var alias *projects.ProjectAlias 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) project = cache.GetProjectByAlias(alias)
plog.Info("Perfect alias match... flawless") plog.Info("Perfect alias match... flawless")
} else { } else {
// Get fuzzy if we don't have an exact match // Get fuzzy if we don't have an exact match
aliases := cache.FuzzyFindAlias(searchString) aliases := cache.FuzzyFindAlias(opts.Search)
if len(aliases) > 1 { if len(aliases) > 1 {
// If multiple aliases were found, switch over to project // If multiple aliases were found, switch over to project
// by alias mode with merging // by alias mode with merging
// alias = fzfAliasFromAliases(rootCmd.Context(), aliases) // alias = fzfAliasFromAliases(rootCmd.Context(), aliases)
project, _ = fzfProjectFromAliases(rootCmd.Context(), aliases) project, _ = fzfProjectFromAliases(opts, aliases)
} else if len(aliases) == 1 { } else if len(aliases) == 1 {
alias = aliases[0] alias = aliases[0]
project = cache.GetProjectByAlias(alias) project = cache.GetProjectByAlias(alias)
@ -67,14 +75,14 @@ func fzfSearchProjectAliases(searchString string) *gitlab.Project {
// a single one. Replaced by fzfProjectFromAliases in fzfSearchProjectAliases // a single one. Replaced by fzfProjectFromAliases in fzfSearchProjectAliases
// as merging is preferred, but can be used if it's ever desirable to // as merging is preferred, but can be used if it's ever desirable to
// return a single alias from all aliases // 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 var alias *projects.ProjectAlias
i, err := fzf.Find( i, err := fzf.Find(
aliases, aliases,
func(i int) string { func(i int) string {
return aliases[i].Alias + " -> " + cache.GetProjectByAlias(aliases[i]).PathWithNamespace return aliases[i].Alias + " -> " + cache.GetProjectByAlias(aliases[i]).PathWithNamespace
}, },
fzf.WithContext(ctx), fzf.WithContext(opts.Ctx),
fzf.WithHeader("Choose an Alias"), fzf.WithHeader("Choose an Alias"),
) )
if err != nil { 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 // Given a list of aliases, merge them together and use the resulting
// list of projects to return a project // 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) { *gitlab.Project, error) {
mergedProjects := projectsFromAliases(aliases) mergedProjects := projectsFromAliases(aliases)
if len(mergedProjects) == 1 { if len(mergedProjects) == 1 {
return mergedProjects[0], nil return mergedProjects[0], nil
} }
return fzfProjectFromProjects(ctx, mergedProjects) return fzfProjectFromProjects(opts, mergedProjects)
} }
func projectsFromAliases(aliases []*projects.ProjectAlias) []*gitlab.Project { func projectsFromAliases(aliases []*projects.ProjectAlias) []*gitlab.Project {
@ -103,7 +111,7 @@ ALIASES:
for _, a := range aliases { for _, a := range aliases {
for _, p := range projects { for _, p := range projects {
// Already have it // Already have it
if a.ProjectID == p.ID { if a.ProjectID == p.ID && a.Remote == p.Remote {
continue ALIASES continue ALIASES
} }
} }
@ -113,20 +121,22 @@ ALIASES:
return projects 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 // 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 var searchableProjects []*gitlab.Project
if len(mustHaveAlias) == 1 && mustHaveAlias[0] { if opts.MustHaveAlias {
searchableProjects = cache.GetProjectsWithAliases() searchableProjects = cache.GetProjectsWithAliases()
} else { } else {
searchableProjects = cache.Projects 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 // 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) { *gitlab.Project, error) {
i, err := fzf.Find(projects, i, err := fzf.Find(projects,
func(i int) string { func(i int) string {
@ -138,7 +148,7 @@ func fzfProjectFromProjects(ctx context.Context, projects []*gitlab.Project) (
return cache.ProjectString(projects[i]) return cache.ProjectString(projects[i])
}, },
), ),
fzf.WithContext(ctx), fzf.WithContext(opts.Ctx),
fzf.WithHeader("Fuzzy find yourself a project"), fzf.WithHeader("Fuzzy find yourself a project"),
) )
if err != nil || i < 0 { if err != nil || i < 0 {
@ -152,6 +162,20 @@ func fzfPreviewWindow(i, w, h int) string {
return cache.ProjectString(p) 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 // Nearly useless function that simply returns either an
// empty string, or a string from the first arg if one is provided // empty string, or a string from the first arg if one is provided
func searchStringFromArgs(args []string) string { func searchStringFromArgs(args []string) string {

View File

@ -22,9 +22,40 @@ func initProjectCache(cmd *cobra.Command, args []string) {
plog.Debug("Running pre-run for cacheCmd") plog.Debug("Running pre-run for cacheCmd")
conf.Cache.File = conf.ProjectPath + "/.cache.yaml" 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 { 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) os.Exit(1)
} }
@ -33,7 +64,7 @@ func initProjectCache(cmd *cobra.Command, args []string) {
Path: conf.Cache.File, Path: conf.Cache.File,
TTL: conf.Cache.Ttl, TTL: conf.Cache.Ttl,
Logger: plog, Logger: plog,
Gitlab: gitlabClient, Gitlabs: gitlabs,
Config: &conf, Config: &conf,
} }
if cache, err = projects.NewProjectCache(cacheOpts); err != nil { 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)) plog.Error("Cache load failed", plog.Args("error", err))
os.Exit(1) os.Exit(1)
} }
plog.Debug("Gitlab Clients", plog.Args("gitlabs", cacheOpts.Gitlabs))
} }
func postProjectCache(cmd *cobra.Command, args []string) { func postProjectCache(cmd *cobra.Command, args []string) {

View File

@ -3,15 +3,24 @@ package config
import "time" import "time"
type Config struct { type Config struct {
Editor editorConfig `yaml:"editor" json:"editor"` // Named keys above maintained for backwards compatibility
GitlabHost string `yaml:"gitlabHost" json:"gitlabHost"` // Ideally only Gitlabs is used
GitlabHost string `yaml:"gitlabHost,omitempty" json:"gitlabHost,omitempty"`
GitlabToken string `yaml:"gitlabToken,omitempty" json:"gitlabToken,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"` LogLevel string `yaml:"logLevel" json:"logLevel" enum:"info,warn,debug,error"`
ProjectPath string `yaml:"projectPath" json:"projectPath"` ProjectPath string `yaml:"projectPath" json:"projectPath"`
Cache cacheConfig `yaml:"cache" json:"cache"` Cache cacheConfig `yaml:"cache" json:"cache"`
Dump struct { 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 { type editorConfig struct {
@ -38,10 +47,13 @@ type cacheConfig struct {
} }
var DefaultConfig = Config{ var DefaultConfig = Config{
GitlabHost: "https://gitlab.com",
GitlabToken: "yourtokenhere",
LogLevel: "warn", LogLevel: "warn",
ProjectPath: "~/work/projects", ProjectPath: "~/work/projects",
Gitlabs: []GitlabConfig{{
Host: "https://gitlab.com",
Token: "yourtokenhere",
Name: "GitLab",
}},
Cache: cacheConfig{ Cache: cacheConfig{
Ttl: 168 * time.Hour, Ttl: 168 * time.Hour,
Load: loadConfig{ Load: loadConfig{

View File

@ -1,10 +1,8 @@
package gitlab package gitlab
import ( import (
"context"
"fmt" "fmt"
"strings" "strings"
"sync"
"time" "time"
"github.com/go-git/go-git/v5" "github.com/go-git/go-git/v5"
@ -21,11 +19,6 @@ const (
projectsPerGoroutine = 200 projectsPerGoroutine = 200
) )
type Client struct {
Ctx context.Context
gitlab *gitlab.Client
}
type Project struct { type Project struct {
ID int ID int
Description string Description string
@ -39,6 +32,8 @@ type Project struct {
AvatarURL string AvatarURL string
LastActivityAt time.Time LastActivityAt time.Time
Readme string Readme string
Remote string
Owner string
Languages *ProjectLanguages Languages *ProjectLanguages
gitRepo *git.Repository gitRepo *git.Repository
} }
@ -58,22 +53,16 @@ type User struct {
AvatarURL string AvatarURL string
} }
type ProgressInfo struct { func (c *Client) Api() *gitlab.Client {
ProgressChan chan Progress return c.apiClient
ProjectsChan chan []*Project
ErrorChan chan error
DoneChan chan interface{}
}
type Progress struct {
Page int
Pages int
Projects int
TotalProjects int
} }
func (p *Project) String() string { 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 { func (p *Project) GetLanguage() *ProjectLanguage {
@ -105,117 +94,7 @@ func (p *Project) GetRepo() *git.Repository {
return p.gitRepo return p.gitRepo
} }
func (p *Project) GetGitInfo() string { func (c *Client) GetTotalProjects(opts *gitlab.ListProjectsOptions) int {
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 {
reqOpts := *opts reqOpts := *opts
reqOpts.ListOptions = gitlab.ListOptions{ reqOpts.ListOptions = gitlab.ListOptions{
Page: 1, Page: 1,
@ -223,7 +102,7 @@ func (c *Client) getTotalProjects(opts *gitlab.ListProjectsOptions) int {
} }
var projects 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 projects = r.TotalItems
} }
@ -235,7 +114,7 @@ func (c *Client) getTotalProjects(opts *gitlab.ListProjectsOptions) int {
func (c *Client) ListProjects(opts *gitlab.ListProjectsOptions) ( func (c *Client) ListProjects(opts *gitlab.ListProjectsOptions) (
[]*Project, *gitlab.Response, error) { []*Project, *gitlab.Response, error) {
pList := make([]*Project, 0) pList := make([]*Project, 0)
projects, resp, err := c.gitlab.Projects.ListProjects( projects, resp, err := c.apiClient.Projects.ListProjects(
opts, opts,
gitlab.WithContext(c.Ctx), gitlab.WithContext(c.Ctx),
) )
@ -245,35 +124,10 @@ func (c *Client) ListProjects(opts *gitlab.ListProjectsOptions) (
return pList, resp, err 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 // A nil return indicates an API error or GitLab doesn't know what
// language the project uses. // language the project uses.
func (c *Client) GetProjectLanguages(project *gitlab.Project) *ProjectLanguages { 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 { if e != nil {
pterm.Error.Printfln("Failed requesting project languages: %s", e.Error()) pterm.Error.Printfln("Failed requesting project languages: %s", e.Error())
return nil return nil
@ -293,15 +147,3 @@ func (c *Client) GetProjectLanguages(project *gitlab.Project) *ProjectLanguages
return &pLangs 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
}

View File

@ -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
}

View File

@ -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)
}

View File

@ -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
}

View File

@ -3,7 +3,6 @@ package projects
import ( import (
"fmt" "fmt"
"os" "os"
"strings"
"sync" "sync"
"time" "time"
@ -19,12 +18,13 @@ type Cache struct {
Updated time.Time Updated time.Time
config *config.Config config *config.Config
readFromFile bool readFromFile bool
lock *sync.Mutex lock *sync.Mutex // Lock the entire cache
contentLock *sync.Mutex // Lock projects / aliases
ttl time.Duration ttl time.Duration
file string file string
log *pterm.Logger log *pterm.Logger
gitlab *gitlab.Client
path string path string
gitlabs *gitlab.Clients
} }
type CacheOpts struct { type CacheOpts struct {
@ -32,7 +32,7 @@ type CacheOpts struct {
ProjectsPath string ProjectsPath string
TTL time.Duration TTL time.Duration
Logger *pterm.Logger Logger *pterm.Logger
Gitlab *gitlab.Client Gitlabs *gitlab.Clients
Config *config.Config Config *config.Config
} }
@ -94,6 +94,10 @@ func (c *Cache) write() {
func (c *Cache) Write() { func (c *Cache) Write() {
c.lock.Lock() c.lock.Lock()
defer c.lock.Unlock() defer c.lock.Unlock()
c.log.Debug("Saving cache to disk", c.log.Args(
"projects", len(c.Projects),
"aliases", len(c.Aliases),
))
c.write() c.write()
} }
@ -136,7 +140,11 @@ func (c *Cache) Clear(clearAliases bool) {
func (c *Cache) refresh() { func (c *Cache) refresh() {
c.log.Info("Loading project cache, this may take a while\n") c.log.Info("Loading project cache, this may take a while\n")
defer c.setUpdated() 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 // Iterates through all GitLab projects the user has access to, updating
@ -148,39 +156,16 @@ func (c *Cache) Refresh() {
} }
func (c *Cache) String() string { 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(), c.Updated.String(),
len(c.Projects), len(c.Projects),
len(c.Aliases)) len(c.Aliases),
} len(*c.gitlabs),
)
// This command will only dump projects that have for _, r := range *c.gitlabs {
// been cloned locally. Setting all to true will list all projects cacheString += " " + r.Config.Host
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")
} }
str.WriteString(c.String() + "\n\n" + term + ":\n") return cacheString
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()
} }
func (c *Cache) setUpdated() { func (c *Cache) setUpdated() {
@ -207,6 +192,14 @@ func NewProjectCache(opts *CacheOpts) (*Cache, error) {
err = createProjectCache(opts.Path) 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{ cache := &Cache{
Projects: make([]*gitlab.Project, 0), Projects: make([]*gitlab.Project, 0),
Aliases: make([]*ProjectAlias, 0), Aliases: make([]*ProjectAlias, 0),
@ -214,8 +207,9 @@ func NewProjectCache(opts *CacheOpts) (*Cache, error) {
file: opts.Path, file: opts.Path,
ttl: opts.TTL, ttl: opts.TTL,
lock: &sync.Mutex{}, lock: &sync.Mutex{},
contentLock: &sync.Mutex{},
log: opts.Logger, log: opts.Logger,
gitlab: opts.Gitlab, gitlabs: gitlabs,
path: opts.ProjectsPath, path: opts.ProjectsPath,
} }

View File

@ -2,8 +2,6 @@ package projects
import ( import (
"errors" "errors"
"fmt"
"strings"
"gitlab.sweetwater.com/it/devops/tools/gitlab-project-manager/internal/gitlab" "gitlab.sweetwater.com/it/devops/tools/gitlab-project-manager/internal/gitlab"
"golang.org/x/exp/slices" "golang.org/x/exp/slices"
@ -23,7 +21,7 @@ func (c *Cache) DeleteAlias(alias *ProjectAlias) {
c.deleteAlias(alias) 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 { if c.GetAliasByName(alias) != nil {
return errors.New("Failed to add alias, already exists") return errors.New("Failed to add alias, already exists")
} }
@ -32,15 +30,16 @@ func (c *Cache) addAlias(alias string, projectID int) error {
&ProjectAlias{ &ProjectAlias{
Alias: alias, Alias: alias,
ProjectID: projectID, ProjectID: projectID,
Remote: remote,
}) })
return nil 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() c.lock.Lock()
defer c.lock.Unlock() defer c.lock.Unlock()
return c.addAlias(alias, projectID) return c.addAlias(alias, projectID, remote)
} }
func (c *Cache) GetProjectsWithAliases() []*gitlab.Project { func (c *Cache) GetProjectsWithAliases() []*gitlab.Project {
@ -55,20 +54,26 @@ func (c *Cache) GetProjectsWithAliases() []*gitlab.Project {
return projectList return projectList
} }
func (c *Cache) GetProjectAliasStrings(project *gitlab.Project) []string { // This method only exists if a cache was built
aliases := c.GetProjectAliases(project) // before multi-remote support. Upon the first load
strings := make([]string, len(aliases)) // with multi-remotes, this will be run first to update
for i, a := range c.GetProjectAliases(project) { // any missing alias remotes
strings[i] = a.Alias 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 { func (c *Cache) setAliasRemote(alias *ProjectAlias) {
aliases := c.GetProjectAliasStrings(project) project := c.GetProjectByID(alias.ProjectID)
return fmt.Sprintf("%s (%s) -> %s", if project != nil {
project.Name, alias.Remote = project.Remote
strings.Join(aliases, ", "), c.log.Debug("Fixed missing alias remote", c.log.Args(
project.PathWithNamespace, "alias", alias.Alias,
) "projectID", alias.ProjectID,
"remote", alias.Remote,
))
}
} }

View File

@ -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
}
}
}

View File

@ -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()
}

View File

@ -4,6 +4,7 @@ import (
"strings" "strings"
"github.com/lithammer/fuzzysearch/fuzzy" "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 // Performs a fuzzy find on the input string, returning the closest
@ -35,3 +36,22 @@ func (c *Cache) FuzzyFindAlias(name string) []*ProjectAlias {
} }
return aliases 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
}

View File

@ -1,46 +1,13 @@
package projects package projects
import ( import (
"bytes"
"fmt"
"strings" "strings"
"text/tabwriter"
"github.com/pterm/pterm" "github.com/pterm/pterm"
"gitlab.sweetwater.com/it/devops/tools/gitlab-project-manager/internal/gitlab" "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 { func (c *Cache) ProjectString(p *gitlab.Project) string {
info := strings.Builder{} info := strings.Builder{}
@ -72,26 +39,7 @@ func (c *Cache) ProjectStrings(prefix string) []string {
projects = append(projects, p.NameWithNamespace) projects = append(projects, p.NameWithNamespace)
} }
} }
return projects return slices.Clip(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
} }
func (c *Cache) GetProjectByPath(path string) *gitlab.Project { func (c *Cache) GetProjectByPath(path string) *gitlab.Project {
@ -103,6 +51,15 @@ func (c *Cache) GetProjectByPath(path string) *gitlab.Project {
return nil 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 { func (c *Cache) GetProjectByID(id int) *gitlab.Project {
for _, p := range c.Projects { for _, p := range c.Projects {
if p.ID == id { if p.ID == id {
@ -112,63 +69,15 @@ func (c *Cache) GetProjectByID(id int) *gitlab.Project {
return nil return nil
} }
func (c *Cache) GetProjectByAlias(alias *ProjectAlias) *gitlab.Project { // Plural form of GetProjectByID
if alias == nil { // Since multiple remotes may have the same project ID,
return nil // this will return all matching
} func (c *Cache) GetProjectsByID(id int) []*gitlab.Project {
projects := make([]*gitlab.Project, 0)
for _, p := range c.Projects { for _, p := range c.Projects {
if p.ID == alias.ProjectID { if p.ID == id {
return p projects = append(projects, 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
} }
} }
return projects
} }

View File

@ -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
}