diff --git a/cmd/alias.go b/cmd/alias.go index 4a1cb96..91e5a0c 100644 --- a/cmd/alias.go +++ b/cmd/alias.go @@ -1,57 +1,18 @@ -/* -Copyright © 2023 Ryan McGuire - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -THE SOFTWARE. -*/ package cmd import ( - "fmt" - "github.com/spf13/cobra" ) -// aliasCmd represents the alias command var aliasCmd = &cobra.Command{ Use: "alias", - Short: "A brief description of your command", - Long: `A longer description that spans multiple lines and likely contains examples -and usage of using your command. For example: - -Cobra is a CLI library for Go that empowers applications. -This application is a tool to generate the needed files -to quickly create a Cobra application.`, - Run: func(cmd *cobra.Command, args []string) { - fmt.Println("alias called") - }, + Short: "Manage project aliases", + Long: aliasCmdLong, + // Just re-use the hooks for project + PersistentPreRun: initProjectCmd, + PersistentPostRun: postProjectCmd, } func init() { rootCmd.AddCommand(aliasCmd) - - // Here you will define your flags and configuration settings. - - // Cobra supports Persistent Flags which will work for this command - // and all subcommands, e.g.: - // aliasCmd.PersistentFlags().String("foo", "", "A help for foo") - - // Cobra supports local flags which will only run when this command - // is called directly, e.g.: - // aliasCmd.Flags().BoolP("toggle", "t", false, "Help message for toggle") } diff --git a/cmd/alias_add.go b/cmd/alias_add.go new file mode 100644 index 0000000..eef6199 --- /dev/null +++ b/cmd/alias_add.go @@ -0,0 +1,95 @@ +package cmd + +import ( + "fmt" + "strings" + + "github.com/pterm/pterm" + "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" +) + +var aliasAddCmd = &cobra.Command{ + Use: "add", + Short: "Add a project alias", + Long: aliasAddCmdLong, + Run: runAddAliasCmd, +} + +func runAddAliasCmd(cmd *cobra.Command, args []string) { + var project *gitlab.Project + + // Check by flag + if projectID := viper.GetInt("alias.add.projectid"); projectID > 0 { + plog.Debug(fmt.Sprintf("Adding for inbound project ID %d", projectID)) + project = cache.GetProjectByID(projectID) + } + + // Collect by fzf + if project == nil { + var err error + project, err = fzfProject(cmd.Context()) + if err != nil || project == nil { + plog.Fatal("No project to alias, nothing to do", plog.Args("error", err)) + } + } + + addNewAliases(project.ID) +} + +func addNewAliases(projectID int) { + project := cache.GetProjectByID(projectID) + if project == nil { + plog.Error("Failed to find project to alias", plog.Args("projectID", projectID)) + return + } + + // Collect the aliases + aliases := promptAliasesForProject(project) + + // Add aliases + for _, a := range aliases { + a = strings.Trim(a, " '\"%") + if a == "" { + continue + } + if err := cache.AddAlias(a, project.ID); err != nil { + plog.Debug("Skipping alias add", plog.Args( + "error", err, + "alias", a, + )) + } else { + plog.Info("Successfully added alias to project", plog.Args( + "project", project.String(), + "alias", a, + )) + } + } +} + +func promptAliasesForProject(p *gitlab.Project) []string { + aliases := cache.GetProjectAliases(p) + if len(aliases) > 0 { + plog.Info("Adding aliases to project", plog.Args( + "project", p.String(), + "existingAliases", projects.ProjectAliasesString(aliases), + )) + } else { + pterm.Info.Printfln("Adding aliases to %s", p.Name) + } + + response, _ := pterm.DefaultInteractiveTextInput. + WithMultiLine(false). + WithDefaultValue(p.Path + " "). + Show("Enter aliases separated by space") + + return strings.Split(response, " ") +} + +func init() { + aliasCmd.AddCommand(aliasAddCmd) + aliasAddCmd.PersistentFlags().Int("projectID", 0, "Specify a project by ID") + viper.BindPFlag("alias.add.projectID", aliasAddCmd.Flag("projectID")) +} diff --git a/cmd/alias_delete.go b/cmd/alias_delete.go new file mode 100644 index 0000000..eef6199 --- /dev/null +++ b/cmd/alias_delete.go @@ -0,0 +1,95 @@ +package cmd + +import ( + "fmt" + "strings" + + "github.com/pterm/pterm" + "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" +) + +var aliasAddCmd = &cobra.Command{ + Use: "add", + Short: "Add a project alias", + Long: aliasAddCmdLong, + Run: runAddAliasCmd, +} + +func runAddAliasCmd(cmd *cobra.Command, args []string) { + var project *gitlab.Project + + // Check by flag + if projectID := viper.GetInt("alias.add.projectid"); projectID > 0 { + plog.Debug(fmt.Sprintf("Adding for inbound project ID %d", projectID)) + project = cache.GetProjectByID(projectID) + } + + // Collect by fzf + if project == nil { + var err error + project, err = fzfProject(cmd.Context()) + if err != nil || project == nil { + plog.Fatal("No project to alias, nothing to do", plog.Args("error", err)) + } + } + + addNewAliases(project.ID) +} + +func addNewAliases(projectID int) { + project := cache.GetProjectByID(projectID) + if project == nil { + plog.Error("Failed to find project to alias", plog.Args("projectID", projectID)) + return + } + + // Collect the aliases + aliases := promptAliasesForProject(project) + + // Add aliases + for _, a := range aliases { + a = strings.Trim(a, " '\"%") + if a == "" { + continue + } + if err := cache.AddAlias(a, project.ID); err != nil { + plog.Debug("Skipping alias add", plog.Args( + "error", err, + "alias", a, + )) + } else { + plog.Info("Successfully added alias to project", plog.Args( + "project", project.String(), + "alias", a, + )) + } + } +} + +func promptAliasesForProject(p *gitlab.Project) []string { + aliases := cache.GetProjectAliases(p) + if len(aliases) > 0 { + plog.Info("Adding aliases to project", plog.Args( + "project", p.String(), + "existingAliases", projects.ProjectAliasesString(aliases), + )) + } else { + pterm.Info.Printfln("Adding aliases to %s", p.Name) + } + + response, _ := pterm.DefaultInteractiveTextInput. + WithMultiLine(false). + WithDefaultValue(p.Path + " "). + Show("Enter aliases separated by space") + + return strings.Split(response, " ") +} + +func init() { + aliasCmd.AddCommand(aliasAddCmd) + aliasAddCmd.PersistentFlags().Int("projectID", 0, "Specify a project by ID") + viper.BindPFlag("alias.add.projectID", aliasAddCmd.Flag("projectID")) +} diff --git a/cmd/cache.go b/cmd/cache.go index bdcee8b..d40f6c0 100644 --- a/cmd/cache.go +++ b/cmd/cache.go @@ -8,16 +8,12 @@ import ( "gitlab.sweetwater.com/it/devops/tools/gitlab-project-manager/internal/projects" ) -const desc = `Contains sub-commands for managing project cache. -The project cache keeps this speedy, without smashing against the GitLab -API every time a new project is added / searched for` - var cache *projects.Cache var cacheCmd = &cobra.Command{ Use: "cache", Short: "Manage GitLab project cache", - Long: desc, + Long: cacheCmdLong, PersistentPreRun: initCacheCmd, PersistentPostRun: postCacheCmd, } diff --git a/cmd/clear.go b/cmd/cache_clear.go similarity index 100% rename from cmd/clear.go rename to cmd/cache_clear.go diff --git a/cmd/dump.go b/cmd/cache_dump.go similarity index 50% rename from cmd/dump.go rename to cmd/cache_dump.go index c1149bf..01e33cb 100644 --- a/cmd/dump.go +++ b/cmd/cache_dump.go @@ -4,6 +4,7 @@ import ( "fmt" "github.com/spf13/cobra" + "github.com/spf13/viper" ) var dumpCmd = &cobra.Command{ @@ -11,10 +12,16 @@ var dumpCmd = &cobra.Command{ Short: "Dump GitLab project cache", Long: `Dumps cache to display`, Run: func(cmd *cobra.Command, args []string) { - fmt.Println(cache.String()) + if conf.Dump.Full { + fmt.Println(cache.DumpString()) + } else { + plog.Info(cache.String()) + } }, } func init() { cacheCmd.AddCommand(dumpCmd) + dumpCmd.PersistentFlags().BoolP("full", "f", false, "Dumps entire cache") + viper.BindPFlag("dump.full", dumpCmd.LocalFlags().Lookup("full")) } diff --git a/cmd/load.go b/cmd/cache_load.go similarity index 100% rename from cmd/load.go rename to cmd/cache_load.go diff --git a/cmd/project.go b/cmd/project.go new file mode 100644 index 0000000..0a0fb75 --- /dev/null +++ b/cmd/project.go @@ -0,0 +1,48 @@ +package cmd + +import ( + "github.com/spf13/cobra" + "gitlab.sweetwater.com/it/devops/tools/gitlab-project-manager/internal/projects" +) + +var projectCmd = &cobra.Command{ + Use: "project [fuzzy alias search]", + Short: "Use a GitLab project", + Args: cobra.MaximumNArgs(1), + ArgAliases: []string{"alias"}, + ValidArgsFunction: validAliasesFunc, + Long: projCmdLong, + PersistentPreRun: initProjectCmd, + PersistentPostRun: postProjectCmd, + Run: projectCmdRun, +} + +func projectCmdRun(cmd *cobra.Command, args []string) { + var searchString string + if len(args) > 0 { + searchString = args[0] + } + project := fzfFindProject(searchString) + + if project == nil { + plog.Fatal("Failed to find a project, nothing to do") + } else { + plog.Info("Houston, we have a project", plog.Args( + "project", project.String(), + "aliases", projects.ProjectAliasesString( + cache.GetProjectAliases(project)), + )) + } +} + +func initProjectCmd(cmd *cobra.Command, args []string) { + initProjectCache(cmd, args) +} + +func postProjectCmd(cmd *cobra.Command, args []string) { + postProjectCache(cmd, args) +} + +func init() { + rootCmd.AddCommand(projectCmd) +} diff --git a/cmd/project_show.go b/cmd/project_show.go new file mode 100644 index 0000000..5bcd752 --- /dev/null +++ b/cmd/project_show.go @@ -0,0 +1,44 @@ +package cmd + +import ( + "fmt" + + "github.com/pterm/pterm" + "github.com/spf13/cobra" +) + +var projectShowCmd = &cobra.Command{ + Use: "show [fuzzy alias search]", + Short: "Show detail for a GitLab project", + Args: cobra.ArbitraryArgs, + ValidArgsFunction: validProjectsOrAliasesFunc, + Long: projShowCmdLong, + Run: projectShowCmdRun, +} + +func projectShowCmdRun(cmd *cobra.Command, args []string) { + var searchString string + if len(args) > 0 { + searchString = args[0] + } + + project := fzfFindProject(searchString) + + if project == nil { + var err error + project, err = fzfProject(cmd.Context()) + if err != nil || project == nil { + plog.Fatal("Failed to find project, nothing to show", plog.Args( + "error", err, + )) + } + } + + fmt.Println(pterm.LightGreen("\n--------------")) + fmt.Println(cache.ProjectString(project)) + fmt.Println(pterm.LightGreen("--------------\n")) +} + +func init() { + projectCmd.AddCommand(projectShowCmd) +} diff --git a/cmd/root.go b/cmd/root.go index 29db9a0..366a632 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -12,21 +12,13 @@ import ( "gitlab.sweetwater.com/it/devops/tools/gitlab-project-manager/internal/config" ) -const ( - defGitlabHost = "gitlab.sweetwater.com" - defProjectsPath = "~/work/projects" - defLogLevel = "info" -) - var conf config.Config var plog *pterm.Logger var rootCmd = &cobra.Command{ - Use: "gitlab-project-manager", - Short: "Find and use GitLab projects locally", - Long: `Finds GitLab projects using fuzzy-find, remembering -your chosen term for the project as an alias, and offers helpful -shortcuts for moving around in projects and opening your code`, + Use: "gitlab-project-manager", + Short: "Find and use GitLab projects locally", + Long: rootCmdLong, PersistentPreRun: initRootCmd, } diff --git a/cmd/util_completion.go b/cmd/util_completion.go new file mode 100644 index 0000000..2514b17 --- /dev/null +++ b/cmd/util_completion.go @@ -0,0 +1,20 @@ +package cmd + +import "github.com/spf13/cobra" + +func validProjectsFunc(cmd *cobra.Command, args []string, toComplete string) ( + []string, cobra.ShellCompDirective) { + return cache.ProjectStrings(), cobra.ShellCompDirectiveDefault +} + +func validAliasesFunc(cmd *cobra.Command, args []string, toComplete string) ( + []string, cobra.ShellCompDirective) { + return cache.AliasStrings(), cobra.ShellCompDirectiveDefault +} + +func validProjectsOrAliasesFunc(cmd *cobra.Command, args []string, toComplete string) ( + []string, cobra.ShellCompDirective) { + projectStrings, _ := validAliasesFunc(cmd, args, toComplete) + aliasStrings, _ := validProjectsFunc(cmd, args, toComplete) + return append(projectStrings, aliasStrings...), cobra.ShellCompDirectiveDefault +} diff --git a/cmd/util_constants.go b/cmd/util_constants.go new file mode 100644 index 0000000..f2afdd8 --- /dev/null +++ b/cmd/util_constants.go @@ -0,0 +1,28 @@ +package cmd + +const ( + defGitlabHost = "gitlab.sweetwater.com" + defProjectsPath = "~/work/projects" + defLogLevel = "info" +) + +const aliasCmdLong = `Manages project aliases, with options for +listing, adding, and deleting.` + +const aliasAddCmdLong = `Adds a project alias to a project +project ID can be provided, or will otherwise use fuzzy find` + +const cacheCmdLong = `Contains sub-commands for managing project cache. +The project cache keeps this speedy, without smashing against the GitLab +API every time a new project is added / searched for` + +const rootCmdLong = `Finds GitLab projects using fuzzy-find, remembering +your chosen term for the project as an alias, and offers helpful +shortcuts for moving around in projects and opening your code` + +const projCmdLong = `Switches to a GitLab project by name or alias +If not found, will enter fzf mode. If not cloned, will clone +the project locally.` + +const projShowCmdLong = `Shows detail for a particular project +Will always fuzzy find` diff --git a/cmd/util_fzf.go b/cmd/util_fzf.go new file mode 100644 index 0000000..28a0d49 --- /dev/null +++ b/cmd/util_fzf.go @@ -0,0 +1,83 @@ +package cmd + +import ( + "context" + + 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" +) + +func fzfFindProject(searchString string) *gitlab.Project { + var project *gitlab.Project + + if searchString != "" { + project = fzfSearchProjectAliases(searchString) + } else { + var err error + project, err = fzfProject(rootCmd.Context()) + if project == nil || err != nil { + return nil + } + } + + return project +} + +func fzfSearchProjectAliases(searchString string) *gitlab.Project { + var project *gitlab.Project + var alias *projects.ProjectAlias + if alias = cache.GetAliasByName(searchString); 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) + if len(aliases) > 1 { + alias = fzfAliasFromAliases(aliasAddCmd.Context(), aliases) + } else if len(aliases) == 1 { + alias = aliases[0] + } + project = cache.GetProjectByAlias(alias) + } + return project +} + +func fzfAliasFromAliases(ctx context.Context, 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.WithHeader("Choose an Alias"), + ) + if err != nil { + plog.Error("Failed to fzf alias slice", plog.Args("error", err)) + } else { + alias = aliases[i] + } + return alias +} + +func fzfProject(ctx context.Context) (*gitlab.Project, error) { + i, err := fzf.Find(cache.Projects, fzfProjectString, + fzf.WithPreviewWindow(fzfPreviewWindow), + fzf.WithContext(ctx), + fzf.WithHeader("Fuzzy find yourself a project"), + ) + if err != nil || i < 0 { + return nil, err + } + return cache.Projects[i], nil +} + +func fzfPreviewWindow(i, w, h int) string { + p := cache.Projects[i] + return cache.ProjectString(p) +} + +func fzfProjectString(i int) string { + return cache.Projects[i].String() +} diff --git a/cmd/init.go b/cmd/util_init.go similarity index 93% rename from cmd/init.go rename to cmd/util_init.go index a129bec..e8ac1eb 100644 --- a/cmd/init.go +++ b/cmd/util_init.go @@ -18,8 +18,8 @@ import ( // func from their PersistentPreRun commands func initProjectCache(cmd *cobra.Command, args []string) { - plog.Debug("Running persistent pre-run for cacheCmd") - conf.Cache.File = conf.ProjectPath + "/.cache.json" + plog.Debug("Running pre-run for cacheCmd") + conf.Cache.File = conf.ProjectPath + "/.cache.yaml" gitlabClient, err := gitlab.NewGitlabClient(cmd.Context(), conf.GitlabHost, conf.GitlabToken) if err != nil { @@ -38,7 +38,7 @@ func initProjectCache(cmd *cobra.Command, args []string) { os.Exit(1) } - if err := cache.Load(); err != nil { + if err := cache.Read(); err != nil { plog.Error("Cache load failed", plog.Args("error", err)) os.Exit(1) } diff --git a/go.mod b/go.mod index 8917341..7f59c1d 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,8 @@ module gitlab.sweetwater.com/it/devops/tools/gitlab-project-manager go 1.21.2 require ( + github.com/ktr0731/go-fuzzyfinder v0.8.0 + github.com/lithammer/fuzzysearch v1.1.8 github.com/pterm/pterm v0.12.71 github.com/spf13/cobra v1.8.0 github.com/spf13/viper v1.17.0 @@ -18,6 +20,8 @@ require ( atomicgo.dev/schedule v0.1.0 // indirect github.com/containerd/console v1.0.3 // indirect github.com/fsnotify/fsnotify v1.6.0 // indirect + github.com/gdamore/encoding v1.0.0 // indirect + github.com/gdamore/tcell/v2 v2.6.0 // indirect github.com/golang/protobuf v1.5.3 // indirect github.com/google/go-querystring v1.1.0 // indirect github.com/gookit/color v1.5.4 // indirect @@ -25,11 +29,14 @@ require ( github.com/hashicorp/go-retryablehttp v0.7.2 // indirect github.com/hashicorp/hcl v1.0.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect - github.com/lithammer/fuzzysearch v1.1.8 // indirect + github.com/ktr0731/go-ansisgr v0.1.0 // indirect + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/magiconair/properties v1.8.7 // indirect github.com/mattn/go-runewidth v0.0.15 // indirect github.com/mitchellh/mapstructure v1.5.0 // indirect + github.com/nsf/termbox-go v1.1.1 // indirect github.com/pelletier/go-toml/v2 v2.1.0 // indirect + github.com/pkg/errors v0.9.1 // indirect github.com/rivo/uniseg v0.4.4 // indirect github.com/sagikazarmark/locafero v0.3.0 // indirect github.com/sagikazarmark/slog-shim v0.1.0 // indirect diff --git a/go.sum b/go.sum index c2f09c9..3190fab 100644 --- a/go.sum +++ b/go.sum @@ -83,6 +83,10 @@ github.com/frankban/quicktest v1.14.4 h1:g2rn0vABPOOXmZUj+vbmUp0lPoXEMuhTpIluN0X github.com/frankban/quicktest v1.14.4/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.6.0 h1:n+5WquG0fcWoWp6xPWfHdbskMCQaFnG6PfBrh1Ky4HY= github.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbSClcnxKAGw= +github.com/gdamore/encoding v1.0.0 h1:+7OoQ1Bc6eTm5niUzBa0Ctsh6JbMW6Ra+YNuAtDBdko= +github.com/gdamore/encoding v1.0.0/go.mod h1:alR0ol34c49FCSBLjhosxzcPHQbf2trDkoo5dl+VrEg= +github.com/gdamore/tcell/v2 v2.6.0 h1:OKbluoP9VYmJwZwq/iLb4BxwKcwGthaa1YNBJIyCySg= +github.com/gdamore/tcell/v2 v2.6.0/go.mod h1:be9omFATkdr0D9qewWW3d+MEvl5dha+Etb5y65J2H8Y= github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= @@ -126,10 +130,12 @@ github.com/google/go-cmp v0.5.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/ github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= -github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= +github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= +github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0= github.com/google/martian/v3 v3.1.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0= @@ -183,21 +189,32 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/ktr0731/go-ansisgr v0.1.0 h1:fbuupput8739hQbEmZn1cEKjqQFwtCCZNznnF6ANo5w= +github.com/ktr0731/go-ansisgr v0.1.0/go.mod h1:G9lxwgBwH0iey0Dw5YQd7n6PmQTwTuTM/X5Sgm/UrzE= +github.com/ktr0731/go-fuzzyfinder v0.8.0 h1:+yobwo9lqZZ7jd1URPdCgZXTE2U1mpIVTkQoo4roi6w= +github.com/ktr0731/go-fuzzyfinder v0.8.0/go.mod h1:Bjpz5im+tppKE9Ii6UK1h+6RaX/lUvJ0ruO4LIYRkqo= github.com/lithammer/fuzzysearch v1.1.8 h1:/HIuJnjHuXS8bKaiTMeeDlW2/AyIWk2brx1V8LFgLN4= github.com/lithammer/fuzzysearch v1.1.8/go.mod h1:IdqeyBClc3FFqSzYq/MXESsS4S0FsZ5ajtkr5xPLts4= +github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= +github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= github.com/mattn/go-isatty v0.0.17 h1:BTarxUcIeDqL27Mc+vyvdWYSL28zpIhv3RoTdsLMPng= github.com/mattn/go-isatty v0.0.17/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/mattn/go-runewidth v0.0.14/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/nsf/termbox-go v1.1.1 h1:nksUPLCb73Q++DwbYUBEglYBRPZyoXJdrj5L+TkjyZY= +github.com/nsf/termbox-go v1.1.1/go.mod h1:T0cTdVuOwf7pHQNtfhnEbzHbcNyCEcVU4YPpouCbVxo= github.com/pelletier/go-toml/v2 v2.1.0 h1:FnwAJ4oYMvbT/34k9zzHuZNrhlz48GB3/s6at6/MHO4= github.com/pelletier/go-toml/v2 v2.1.0/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/sftp v1.13.1/go.mod h1:3HaPG6Dq1ILlpPZRO0HVMrsydcdLt6HRDccSgb87qRg= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= @@ -214,6 +231,7 @@ github.com/pterm/pterm v0.12.40/go.mod h1:ffwPLwlbXxP+rxT0GsgDTzS3y3rmpAO1NMjUkG github.com/pterm/pterm v0.12.71 h1:KcEJ98EiVCbzDkFbktJ2gMlr4pn8IzyGb9bwK6ffkuA= github.com/pterm/pterm v0.12.71/go.mod h1:SUAcoZjRt+yjPWlWba+/Fd8zJJ2lSXBQWf0Z0HbFiIQ= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.3/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rivo/uniseg v0.4.4 h1:8TfxU8dW6PdqD27gjM8MVNuicgxIjxpm4K7x4jp8sis= github.com/rivo/uniseg v0.4.4/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= diff --git a/internal/config/config.go b/internal/config/config.go index 6f3e9ef..d104559 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -8,6 +8,9 @@ type Config struct { 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 + } } type cacheConfig struct { diff --git a/internal/gitlab/gitlab.go b/internal/gitlab/gitlab.go index 18ec100..5394adb 100644 --- a/internal/gitlab/gitlab.go +++ b/internal/gitlab/gitlab.go @@ -2,12 +2,13 @@ package gitlab import ( "context" + "fmt" "time" "github.com/xanzy/go-gitlab" ) -const defProjectsPerPage = 100 +const defProjectsPerPage = 30 type Client struct { Ctx context.Context @@ -15,12 +16,11 @@ type Client struct { } type Project struct { - ID int - Description string - SSHURLToRepo string - HTTPURLToRepo string - WebURL string - // Owner User + ID int + Description string + SSHURLToRepo string + HTTPURLToRepo string + WebURL string Name string NameWithNamespace string Path string @@ -51,6 +51,10 @@ type Progress struct { TotalProjects int } +func (p *Project) String() string { + return fmt.Sprintf("%s (%s)", p.Path, p.PathWithNamespace) +} + // 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 @@ -77,6 +81,7 @@ func (c *Client) streamProjects(pi *ProgressInfo) { Page: 1, }, Archived: new(bool), + Owned: gitlab.Ptr[bool](true), } var numProjects int diff --git a/internal/projects/cache.go b/internal/projects/cache.go index c475f8c..4e0ec4f 100644 --- a/internal/projects/cache.go +++ b/internal/projects/cache.go @@ -2,8 +2,8 @@ package projects import ( "fmt" - "io/fs" "os" + "strings" "sync" "time" @@ -48,7 +48,7 @@ func (c *Cache) Load() error { // Saves the current state of the cache to disk func (c *Cache) write() { - file, err := os.OpenFile(c.file, os.O_RDWR, fs.ModeAppend) + file, err := os.OpenFile(c.file, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0640) if err != nil { c.log.Error("Failed to write cache to disk", c.log.Args("error", err)) } @@ -66,7 +66,7 @@ func (c *Cache) Write() { } // Loads and unmarshals the project cache from disk. -func (c *Cache) Read() { +func (c *Cache) Read() error { c.lock.Lock() defer c.lock.Unlock() c.log.Debug("Reading project cache from disk", c.log.Args("file", c.file)) @@ -74,18 +74,14 @@ func (c *Cache) Read() { file, err := os.Open(c.file) if err != nil { c.log.Error("Failed to read project cache", c.log.Args("error", err)) - return + return err } d := yaml.NewDecoder(file) d.Decode(c) - if time.Since(c.Updated) > c.ttl { - c.refresh() - } - c.readFromFile = true - c.log.Debug(c.String()) + return nil } // Resets projects cache and also optionally clears @@ -98,7 +94,6 @@ func (c *Cache) clear(clearAliases bool) { c.Aliases = make([]*ProjectAlias, 0) } c.setUpdated() - c.log.Debug(c.String()) } func (c *Cache) Clear(clearAliases bool) { c.lock.Lock() @@ -127,6 +122,24 @@ func (c *Cache) String() string { len(c.Aliases)) } +func (c *Cache) DumpString() string { + str := strings.Builder{} + str.WriteString(c.String() + "\n\nProjects:\n") + for _, project := range c.Projects { + 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() { c.Updated = time.Now() } diff --git a/internal/projects/cache_aliases.go b/internal/projects/cache_aliases.go new file mode 100644 index 0000000..96d0f93 --- /dev/null +++ b/internal/projects/cache_aliases.go @@ -0,0 +1,23 @@ +package projects + +import "errors" + +func (c *Cache) addAlias(alias string, projectID int) error { + if c.GetAliasByName(alias) != nil { + return errors.New("Failed to add alias, already exists") + } + + c.Aliases = append(c.Aliases, + &ProjectAlias{ + Alias: alias, + ProjectID: projectID, + }) + + return nil +} + +func (c *Cache) AddAlias(alias string, projectID int) error { + c.lock.Lock() + defer c.lock.Unlock() + return c.addAlias(alias, projectID) +} diff --git a/internal/projects/fuzz.go b/internal/projects/fuzz.go new file mode 100644 index 0000000..b0ffb9f --- /dev/null +++ b/internal/projects/fuzz.go @@ -0,0 +1,37 @@ +package projects + +import ( + "strings" + + "github.com/lithammer/fuzzysearch/fuzzy" +) + +// Performs a fuzzy find on the input string, returning the closest +// matched based on its Levenshtein distance, along with an integer +// indicating number of matches found +func (c *Cache) FuzzyFindAlias(name string) []*ProjectAlias { + ranks := fuzzy.RankFindFold(name, c.AliasStrings()) + if ranks.Len() == 1 { + c.log.Debug("Fuzzy found alias result", + c.log.Args( + "searchTerm", ranks[0].Source, + "foundAlias", ranks[0].Target, + "levenshteinDistance", ranks[0].Distance, + )) + } else if ranks.Len() > 1 { + found := make([]string, ranks.Len()) + for i, r := range ranks { + found[i] = r.Target + } + c.log.Warn("Fuzzy found multiple aliases, try being more specific", + c.log.Args("foundAliases", strings.Join(found, ","))) + } + var aliases []*ProjectAlias + if ranks.Len() > 0 { + aliases = make([]*ProjectAlias, ranks.Len()) + for i, r := range ranks { + aliases[i] = c.GetAliasByName(r.Target) + } + } + return aliases +} diff --git a/internal/projects/projects.go b/internal/projects/projects.go index 29aac64..eabb6e6 100644 --- a/internal/projects/projects.go +++ b/internal/projects/projects.go @@ -2,6 +2,7 @@ package projects import ( "fmt" + "strings" "github.com/pterm/pterm" "gitlab.sweetwater.com/it/devops/tools/gitlab-project-manager/internal/gitlab" @@ -9,8 +10,93 @@ import ( type ProjectAlias struct { Alias string - ProjectID string - Project *gitlab.Project + 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) ProjectString(p *gitlab.Project) string { + info := strings.Builder{} + + info.WriteString(pterm.LightBlue(p.Name)) + info.WriteRune('\n') + if p.Description != "" { + info.WriteString(p.Description) + info.WriteRune('\n') + } + + info.WriteString("\nPath: " + pterm.LightGreen(p.PathWithNamespace)) + info.WriteString("\nProjectID: " + pterm.LightGreen(p.ID)) + info.WriteString("\nURL: " + pterm.LightGreen(p.WebURL)) + info.WriteString("\nLastActivity: " + pterm.LightMagenta(p.LastActivityAt.String())) + info.WriteString("\nAliases: ") + + aliases := c.GetProjectAliases(p) + info.WriteString(ProjectAliasesString(aliases)) + + return info.String() +} + +func (c *Cache) ProjectStrings() []string { + projects := make([]string, len(c.Projects)) + for i, p := range c.Projects { + projects[i] = p.NameWithNamespace + } + return projects +} + +func (c *Cache) AliasStrings() []string { + aliases := make([]string, len(c.Aliases)) + for i, a := range c.Aliases { + aliases[i] = 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) GetProjectByID(id int) *gitlab.Project { + for _, p := range c.Projects { + if p.ID == id { + return p + } + } + 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 { + 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() { @@ -46,6 +132,7 @@ func (c *Cache) LoadProjects() { c.log.Warn("LoadProjects cancelled", c.log.Args("reason", c.gitlab.Ctx.Err())) return case <-progressInfo.DoneChan: + pBar.Add(pBar.Total - curProjects) c.log.Info("Project load complete") return } diff --git a/pkg/gitlab/gitlab.go b/pkg/gitlab/gitlab.go deleted file mode 100644 index 0474ce0..0000000 --- a/pkg/gitlab/gitlab.go +++ /dev/null @@ -1,18 +0,0 @@ -package gitlab - -import "github.com/xanzy/go-gitlab" - -type Client struct { - gitlab *gitlab.Client -} - -func NewGitlabClient(host, token string) (*Client, error) { - client, err := gitlab.NewClient(token, gitlab.WithBaseURL(host)) - if err != nil { - return nil, err - } - gitlabClient := &Client{ - gitlab: client, - } - return gitlabClient, nil -}