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:
2024-01-14 15:33:15 +00:00
parent 415290de20
commit b944af140a
27 changed files with 868 additions and 422 deletions

View File

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

View File

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

View File

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

View File

@ -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)?").

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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