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 19 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
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")
zeucapua marked this conversation as resolved.
Show resolved Hide resolved
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
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.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
}
142 changes: 142 additions & 0 deletions cmd/generate/config/spec.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
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]
zeucapua marked this conversation as resolved.
Show resolved Hide resolved

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