Skip to content

Commit

Permalink
Merge pull request #137 from open-sauced/feat/generate-config-yaml
Browse files Browse the repository at this point in the history
feat: automatically generate `.sauced.yaml` file
  • Loading branch information
zeucapua authored Sep 10, 2024
2 parents 07bac4b + 315a97b commit b5bc04e
Show file tree
Hide file tree
Showing 6 changed files with 323 additions and 1 deletion.
137 changes: 137 additions & 0 deletions cmd/generate/config/config.go
Original file line number Diff line number Diff line change
@@ -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
}
35 changes: 35 additions & 0 deletions cmd/generate/config/output.go
Original file line number Diff line number Diff line change
@@ -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
}
145 changes: 145 additions & 0 deletions cmd/generate/config/spec.go
Original file line number Diff line number Diff line change
@@ -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()
}
}
2 changes: 2 additions & 0 deletions cmd/generate/generate.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 = `The 'generate' command provides tools to automate the creation of important project documentation and derive insights from your codebase.`
Expand All @@ -26,6 +27,7 @@ func NewGenerateCommand() *cobra.Command {
}

cmd.AddCommand(codeowners.NewCodeownersCommand())
cmd.AddCommand(config.NewConfigCommand())

return cmd
}
Expand Down
3 changes: 2 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
)
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down

0 comments on commit b5bc04e

Please sign in to comment.