diff --git a/cmd/generate/config/config.go b/cmd/generate/config/config.go new file mode 100644 index 0000000..e5e3e19 --- /dev/null +++ b/cmd/generate/config/config.go @@ -0,0 +1,137 @@ +package config + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "slices" + + tea "github.com/charmbracelet/bubbletea" + "github.com/go-git/go-git/v5" + "github.com/go-git/go-git/v5/plumbing/object" + "github.com/spf13/cobra" +) + +// Options for the config generation command +type Options struct { + // the path to the git repository on disk to generate a codeowners file for + path string + + // where the '.sauced.yaml' file will go + outputPath string + + // whether to use interactive mode + isInteractive bool + + // from global config + ttyDisabled bool +} + +const configLongDesc string = `Generates a ".sauced.yaml" configuration file. The attribution of emails to given entities +is based on the repository this command is ran in.` + +func NewConfigCommand() *cobra.Command { + opts := &Options{} + + cmd := &cobra.Command{ + Use: "config path/to/repo [flags]", + Short: "Generates a \".sauced.yaml\" config based on the current repository", + Long: configLongDesc, + Args: func(_ *cobra.Command, args []string) error { + if len(args) != 1 { + return errors.New("you must provide exactly one argument: the path to the repository") + } + + path := args[0] + + // Validate that the path is a real path on disk and accessible by the user + absPath, err := filepath.Abs(path) + if err != nil { + return err + } + + if _, err := os.Stat(absPath); os.IsNotExist(err) { + return fmt.Errorf("the provided path does not exist: %w", err) + } + + opts.path = absPath + return nil + }, + + RunE: func(cmd *cobra.Command, _ []string) error { + opts.outputPath, _ = cmd.Flags().GetString("output-path") + opts.isInteractive, _ = cmd.Flags().GetBool("interactive") + opts.ttyDisabled, _ = cmd.Flags().GetBool("tty-disable") + + return run(opts) + }, + } + + cmd.PersistentFlags().StringP("output-path", "o", "./", "Directory to create the `.sauced.yaml` file.") + cmd.PersistentFlags().BoolP("interactive", "i", false, "Whether to be interactive") + return cmd +} + +func run(opts *Options) error { + attributionMap := make(map[string][]string) + + // Open repo + repo, err := git.PlainOpen(opts.path) + if err != nil { + return fmt.Errorf("error opening repo: %w", err) + } + + commitIter, err := repo.CommitObjects() + + if err != nil { + return fmt.Errorf("error opening repo commits: %w", err) + } + + var uniqueEmails []string + err = commitIter.ForEach(func(c *object.Commit) error { + name := c.Author.Name + email := c.Author.Email + + if opts.ttyDisabled || !opts.isInteractive { + doesEmailExist := slices.Contains(attributionMap[name], email) + if !doesEmailExist { + // AUTOMATIC: set every name and associated emails + attributionMap[name] = append(attributionMap[name], email) + } + } else if !slices.Contains(uniqueEmails, email) { + uniqueEmails = append(uniqueEmails, email) + } + return nil + }) + + if err != nil { + return fmt.Errorf("error iterating over repo commits: %w", err) + } + + // INTERACTIVE: per unique email, set a name (existing or new or ignore) + if opts.isInteractive && !opts.ttyDisabled { + program := tea.NewProgram(initialModel(opts, uniqueEmails)) + if _, err := program.Run(); err != nil { + return fmt.Errorf("error running interactive mode: %w", err) + } + } else { + // generate an output file + // default: `./.sauced.yaml` + // fallback for home directories + if opts.outputPath == "~/" { + homeDir, _ := os.UserHomeDir() + err := generateOutputFile(filepath.Join(homeDir, ".sauced.yaml"), attributionMap) + if err != nil { + return fmt.Errorf("error generating output file: %w", err) + } + } else { + err := generateOutputFile(filepath.Join(opts.outputPath, ".sauced.yaml"), attributionMap) + if err != nil { + return fmt.Errorf("error generating output file: %w", err) + } + } + } + + return nil +} diff --git a/cmd/generate/config/output.go b/cmd/generate/config/output.go new file mode 100644 index 0000000..f8db065 --- /dev/null +++ b/cmd/generate/config/output.go @@ -0,0 +1,35 @@ +package config + +import ( + "fmt" + "os" + + "github.com/open-sauced/pizza-cli/pkg/config" + "github.com/open-sauced/pizza-cli/pkg/utils" +) + +func generateOutputFile(outputPath string, attributionMap map[string][]string) error { + file, err := os.Create(outputPath) + if err != nil { + return fmt.Errorf("error creating %s file: %w", outputPath, err) + } + defer file.Close() + + var config config.Spec + config.Attributions = attributionMap + + // for pretty print test + yaml, err := utils.OutputYAML(config) + + if err != nil { + return fmt.Errorf("failed to turn into YAML: %w", err) + } + + _, err = file.WriteString(yaml) + + if err != nil { + return fmt.Errorf("failed to turn into YAML: %w", err) + } + + return nil +} diff --git a/cmd/generate/config/spec.go b/cmd/generate/config/spec.go new file mode 100644 index 0000000..5284cc9 --- /dev/null +++ b/cmd/generate/config/spec.go @@ -0,0 +1,145 @@ +package config + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/charmbracelet/bubbles/help" + "github.com/charmbracelet/bubbles/key" + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" +) + +// Bubbletea for Interactive Mode + +type model struct { + textInput textinput.Model + help help.Model + keymap keymap + + opts *Options + attributionMap map[string][]string + uniqueEmails []string + currentIndex int +} + +type keymap struct{} + +func (k keymap) ShortHelp() []key.Binding { + return []key.Binding{ + key.NewBinding(key.WithKeys("ctrl+n"), key.WithHelp("ctrl+n", "next suggestion")), + key.NewBinding(key.WithKeys("ctrl+p"), key.WithHelp("ctrl+p", "prev suggestion")), + key.NewBinding(key.WithKeys("ctrl+i"), key.WithHelp("ctrl+i", "ignore email")), + key.NewBinding(key.WithKeys("esc"), key.WithHelp("esc", "quit")), + key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "submit")), + } +} + +func (k keymap) FullHelp() [][]key.Binding { + return [][]key.Binding{k.ShortHelp()} +} + +func initialModel(opts *Options, uniqueEmails []string) model { + ti := textinput.New() + ti.Placeholder = "username" + ti.Focus() + ti.ShowSuggestions = true + + return model{ + textInput: ti, + help: help.New(), + keymap: keymap{}, + + opts: opts, + attributionMap: make(map[string][]string), + uniqueEmails: uniqueEmails, + currentIndex: 0, + } +} + +func (m model) Init() tea.Cmd { + return textinput.Blink +} + +func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmd tea.Cmd + currentEmail := m.uniqueEmails[m.currentIndex] + + existingUsers := make([]string, 0, len(m.attributionMap)) + for k := range m.attributionMap { + existingUsers = append(existingUsers, k) + } + + m.textInput.SetSuggestions(existingUsers) + + keyMsg, ok := msg.(tea.KeyMsg) + + if ok { + switch keyMsg.Type { + case tea.KeyCtrlC, tea.KeyEsc: + return m, tea.Quit + + case tea.KeyCtrlI: + m.currentIndex++ + if m.currentIndex+1 >= len(m.uniqueEmails) { + return m, runOutputGeneration(m.opts, m.attributionMap) + } + return m, nil + + case tea.KeyEnter: + if len(strings.Trim(m.textInput.Value(), " ")) == 0 { + return m, nil + } + m.attributionMap[m.textInput.Value()] = append(m.attributionMap[m.textInput.Value()], currentEmail) + m.textInput.Reset() + if m.currentIndex+1 >= len(m.uniqueEmails) { + return m, runOutputGeneration(m.opts, m.attributionMap) + } + + m.currentIndex++ + return m, nil + } + } + + m.textInput, cmd = m.textInput.Update(msg) + + return m, cmd +} + +func (m model) View() string { + currentEmail := "" + if m.currentIndex < len(m.uniqueEmails) { + currentEmail = m.uniqueEmails[m.currentIndex] + } + + return fmt.Sprintf( + "Found email %s - who to attribute to?: \n%s\n\n%s\n", + currentEmail, + m.textInput.View(), + m.help.View(m.keymap), + ) +} + +func runOutputGeneration(opts *Options, attributionMap map[string][]string) tea.Cmd { + // generate an output file + // default: `./.sauced.yaml` + // fallback for home directories + return func() tea.Msg { + if opts.outputPath == "~/" { + homeDir, _ := os.UserHomeDir() + err := generateOutputFile(filepath.Join(homeDir, ".sauced.yaml"), attributionMap) + if err != nil { + return fmt.Errorf("error generating output file: %w", err) + } + } else { + err := generateOutputFile(filepath.Join(opts.outputPath, ".sauced.yaml"), attributionMap) + if err != nil { + return fmt.Errorf("error generating output file: %w", err) + } + } + + return tea.Quit() + } +} diff --git a/cmd/generate/generate.go b/cmd/generate/generate.go index b3ff321..fee0146 100644 --- a/cmd/generate/generate.go +++ b/cmd/generate/generate.go @@ -6,6 +6,7 @@ import ( "github.com/spf13/cobra" "github.com/open-sauced/pizza-cli/cmd/generate/codeowners" + "github.com/open-sauced/pizza-cli/cmd/generate/config" ) const generateLongDesc string = `WARNING: Proof of concept feature. @@ -28,6 +29,7 @@ func NewGenerateCommand() *cobra.Command { } cmd.AddCommand(codeowners.NewCodeownersCommand()) + cmd.AddCommand(config.NewConfigCommand()) return cmd } diff --git a/go.mod b/go.mod index bc698f7..cbf822d 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.22 require ( github.com/charmbracelet/bubbles v0.19.0 + github.com/charmbracelet/bubbletea v0.27.1 github.com/charmbracelet/lipgloss v0.13.0 github.com/cli/browser v1.3.0 github.com/go-git/go-git/v5 v5.12.0 @@ -17,7 +18,7 @@ require ( ) require ( - github.com/charmbracelet/bubbletea v0.27.1 // indirect + github.com/atotto/clipboard v0.1.4 // indirect github.com/cpuguy83/go-md2man/v2 v2.0.4 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect ) diff --git a/go.sum b/go.sum index e0f49bc..711eb98 100644 --- a/go.sum +++ b/go.sum @@ -9,6 +9,8 @@ github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFI github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4= github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= +github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8=