Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: automatically generate .sauced.yaml file #137

Merged
merged 20 commits into from
Sep 10, 2024
Merged
Show file tree
Hide file tree
Changes from 14 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
271 changes: 271 additions & 0 deletions cmd/generate/config/config.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,271 @@
package config

import (
"errors"
"fmt"
"os"
"path/filepath"
"slices"
"strings"

"github.com/charmbracelet/bubbles/help"
"github.com/charmbracelet/bubbles/key"
"github.com/charmbracelet/bubbles/textinput"
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
}

const configLongDesc string = `WARNING: Proof of concept feature.

Generates a ~/.sauced.yaml configuration file. The attribution of emails to given entities
is based on the repository this command is ran in.`
zeucapua marked this conversation as resolved.
Show resolved Hide resolved

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",
zeucapua marked this conversation as resolved.
Show resolved Hide resolved
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 {
// TODO: error checking based on given command

zeucapua marked this conversation as resolved.
Show resolved Hide resolved
opts.outputPath, _ = cmd.Flags().GetString("output-path")
opts.isInteractive, _ = cmd.Flags().GetBool("interactive")
zeucapua marked this conversation as resolved.
Show resolved Hide resolved

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)
zeucapua marked this conversation as resolved.
Show resolved Hide resolved
}

commitIter, err := repo.CommitObjects()

if err != nil {
return fmt.Errorf("Error opening repo commits: %w", err)
zeucapua marked this conversation as resolved.
Show resolved Hide resolved
}

var uniqueEmails []string
zeucapua marked this conversation as resolved.
Show resolved Hide resolved
err = commitIter.ForEach(func(c *object.Commit) error {
name := c.Author.Name
email := c.Author.Email

if !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)
zeucapua marked this conversation as resolved.
Show resolved Hide resolved
}

// INTERACTIVE: per unique email, set a name (existing or new or ignore)
if opts.isInteractive {
program := tea.NewProgram(initialModel(opts, uniqueEmails))
if _, err := program.Run(); err != nil {
return fmt.Errorf("Error running interactive mode: %w", err)
zeucapua marked this conversation as resolved.
Show resolved Hide resolved
}
} 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)
zeucapua marked this conversation as resolved.
Show resolved Hide resolved
}
} else {
err := generateOutputFile(filepath.Join(opts.outputPath, ".sauced.yaml"), attributionMap)
if err != nil {
return fmt.Errorf("Error generating output file: %w", err)
zeucapua marked this conversation as resolved.
Show resolved Hide resolved
}
}
}

return nil
}

// 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
}
zeucapua marked this conversation as resolved.
Show resolved Hide resolved

type keymap struct{}
zeucapua marked this conversation as resolved.
Show resolved Hide resolved

func (k keymap) ShortHelp() []key.Binding {
return []key.Binding{
key.NewBinding(key.WithKeys("ctrl+n"), key.WithHelp("ctrl+n", "next")),
key.NewBinding(key.WithKeys("ctrl+p"), key.WithHelp("ctrl+p", "prev")),
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 = "name"
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++
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()
}

}
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)
zeucapua marked this conversation as resolved.
Show resolved Hide resolved
}
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)
zeucapua marked this conversation as resolved.
Show resolved Hide resolved
}

_, err = file.WriteString(yaml)

if err != nil {
return fmt.Errorf("Failed to turn into YAML: %w", err)
zeucapua marked this conversation as resolved.
Show resolved Hide resolved
}

return nil
}
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 = `WARNING: Proof of concept feature.
Expand All @@ -28,6 +29,7 @@ func NewGenerateCommand() *cobra.Command {
}

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

return cmd
}
Expand Down
4 changes: 3 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,9 +18,10 @@ 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

zeucapua marked this conversation as resolved.
Show resolved Hide resolved
)

require (
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
Loading