This commit is contained in:
Ryan McGuire 2023-12-08 16:52:26 -05:00
parent 424e572fe0
commit f17ce69ef8
23 changed files with 648 additions and 104 deletions

View File

@ -1,57 +1,18 @@
/*
Copyright © 2023 Ryan McGuire <ryand_mcguire@sweetwater.com>
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")
}

95
cmd/alias_add.go Normal file
View File

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

95
cmd/alias_delete.go Normal file
View File

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

View File

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

View File

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

48
cmd/project.go Normal file
View File

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

44
cmd/project_show.go Normal file
View File

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

View File

@ -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`,
Long: rootCmdLong,
PersistentPreRun: initRootCmd,
}

20
cmd/util_completion.go Normal file
View File

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

28
cmd/util_constants.go Normal file
View File

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

83
cmd/util_fzf.go Normal file
View File

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

View File

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

9
go.mod
View File

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

22
go.sum
View File

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

View File

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

View File

@ -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
@ -20,7 +21,6 @@ type Project struct {
SSHURLToRepo string
HTTPURLToRepo string
WebURL string
// Owner User
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

View File

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

View File

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

37
internal/projects/fuzz.go Normal file
View File

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

View File

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

View File

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