Add interactive mode to sdkch (#4009)
This commit is contained in:
parent
c0a607ce74
commit
ae2999931b
|
@ -7,12 +7,13 @@ import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"io/ioutil"
|
"io/ioutil"
|
||||||
"log"
|
"log"
|
||||||
"math"
|
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
@ -26,8 +27,9 @@ var (
|
||||||
progName string
|
progName string
|
||||||
verboseLog *log.Logger
|
verboseLog *log.Logger
|
||||||
|
|
||||||
entriesDir string
|
entriesDir string
|
||||||
verboseLogging bool
|
verboseLogging bool
|
||||||
|
interactiveMode bool
|
||||||
|
|
||||||
// sections name-title map
|
// sections name-title map
|
||||||
sections = map[string]string{
|
sections = map[string]string{
|
||||||
|
@ -44,22 +46,92 @@ var (
|
||||||
"sdk": "SDK",
|
"sdk": "SDK",
|
||||||
"tendermint": "Tendermint",
|
"tendermint": "Tendermint",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RootCmd represents the base command when called without any subcommands
|
||||||
|
RootCmd = &cobra.Command{
|
||||||
|
Use: "sdkch",
|
||||||
|
Short: "\nMaintain unreleased changelog entries in a modular fashion.",
|
||||||
|
}
|
||||||
|
|
||||||
|
// command to add a pending log entry
|
||||||
|
AddCmd = &cobra.Command{
|
||||||
|
Use: "add [section] [stanza] [message]",
|
||||||
|
Short: "Add an entry file.",
|
||||||
|
Long: `
|
||||||
|
Add an entry file. If message is empty, start the editor to edit the message.
|
||||||
|
|
||||||
|
Sections Stanzas
|
||||||
|
--- ---
|
||||||
|
breaking gaia
|
||||||
|
features gaiacli
|
||||||
|
improvements gaiarest
|
||||||
|
bugfixes sdk
|
||||||
|
tendermint`,
|
||||||
|
Args: cobra.MaximumNArgs(3),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
|
||||||
|
if interactiveMode {
|
||||||
|
return addEntryFileFromConsoleInput()
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(args) < 2 {
|
||||||
|
log.Println("must include at least 2 arguments when not in interactive mode")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
sectionDir, stanzaDir := args[0], args[1]
|
||||||
|
err := validateSectionStanzaDirs(sectionDir, stanzaDir)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(args) == 3 {
|
||||||
|
return addSinglelineEntryFile(sectionDir, stanzaDir, strings.TrimSpace(args[2]))
|
||||||
|
}
|
||||||
|
return addEntryFile(sectionDir, stanzaDir)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// command to generate the changelog
|
||||||
|
GenerateCmd = &cobra.Command{
|
||||||
|
Use: "generate",
|
||||||
|
Short: "Generate a changelog in Markdown format and print it to STDOUT. version defaults to UNRELEASED.",
|
||||||
|
Args: cobra.NoArgs,
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
version := "UNRELEASED"
|
||||||
|
if flag.NArg() > 1 {
|
||||||
|
version = strings.Join(flag.Args()[1:], " ")
|
||||||
|
}
|
||||||
|
return generateChangelog(version)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// command to delete empty sub-directories recursively
|
||||||
|
PruneCmd = &cobra.Command{
|
||||||
|
Use: "prune",
|
||||||
|
Short: "Delete empty sub-directories recursively.",
|
||||||
|
Args: cobra.NoArgs,
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
return pruneEmptyDirectories()
|
||||||
|
},
|
||||||
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
progName = filepath.Base(os.Args[0])
|
RootCmd.AddCommand(AddCmd)
|
||||||
|
RootCmd.AddCommand(GenerateCmd)
|
||||||
|
RootCmd.AddCommand(PruneCmd)
|
||||||
|
|
||||||
cwd, err := os.Getwd()
|
cwd, err := os.Getwd()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
flag.StringVar(&entriesDir, "d", filepath.Join(cwd, entriesDirName), "entry files directory")
|
AddCmd.Flags().BoolVarP(&interactiveMode, "interactive", "i", false, "get the section/stanza/message with interactive CLI prompts")
|
||||||
flag.BoolVar(&verboseLogging, "v", false, "enable verbose logging")
|
RootCmd.PersistentFlags().BoolVarP(&verboseLogging, "verbose-logging", "v", false, "enable verbose logging")
|
||||||
flag.Usage = printUsage
|
RootCmd.PersistentFlags().StringVarP(&entriesDir, "entries-dir", "d", filepath.Join(cwd, entriesDirName), "entry files directory")
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
|
||||||
logPrefix := fmt.Sprintf("%s: ", filepath.Base(progName))
|
logPrefix := fmt.Sprintf("%s: ", filepath.Base(progName))
|
||||||
log.SetFlags(0)
|
log.SetFlags(0)
|
||||||
log.SetPrefix(logPrefix)
|
log.SetPrefix(logPrefix)
|
||||||
|
@ -70,67 +142,66 @@ func main() {
|
||||||
verboseLog.SetPrefix(logPrefix)
|
verboseLog.SetPrefix(logPrefix)
|
||||||
}
|
}
|
||||||
|
|
||||||
if flag.NArg() < 1 {
|
if err := RootCmd.Execute(); err != nil {
|
||||||
errInsufficientArgs()
|
fmt.Println(err)
|
||||||
}
|
os.Exit(1)
|
||||||
|
|
||||||
cmd := flag.Arg(0)
|
|
||||||
switch cmd {
|
|
||||||
|
|
||||||
case "add":
|
|
||||||
if flag.NArg() < 3 {
|
|
||||||
errInsufficientArgs()
|
|
||||||
}
|
|
||||||
if flag.NArg() > 4 {
|
|
||||||
errTooManyArgs()
|
|
||||||
}
|
|
||||||
sectionDir, stanzaDir := flag.Arg(1), flag.Arg(2)
|
|
||||||
validateSectionStanzaDirs(sectionDir, stanzaDir)
|
|
||||||
if flag.NArg() == 4 {
|
|
||||||
addSinglelineEntryFile(sectionDir, stanzaDir, strings.TrimSpace(flag.Arg(3)))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
addEntryFile(sectionDir, stanzaDir)
|
|
||||||
|
|
||||||
case "generate":
|
|
||||||
version := "UNRELEASED"
|
|
||||||
if flag.NArg() > 1 {
|
|
||||||
version = strings.Join(flag.Args()[1:], " ")
|
|
||||||
}
|
|
||||||
generateChangelog(version)
|
|
||||||
|
|
||||||
case "prune":
|
|
||||||
pruneEmptyDirectories()
|
|
||||||
|
|
||||||
default:
|
|
||||||
unknownCommand(cmd)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func addSinglelineEntryFile(sectionDir, stanzaDir, message string) {
|
func addEntryFileFromConsoleInput() error {
|
||||||
|
|
||||||
|
reader := bufio.NewReader(os.Stdin)
|
||||||
|
fmt.Println("Please enter the section (either: \"breaking\", \"features\", \"improvements\", \"bugfixes\")")
|
||||||
|
sectionDir, _ := reader.ReadString('\n')
|
||||||
|
sectionDir = strings.TrimSpace(sectionDir)
|
||||||
|
if _, ok := sections[sectionDir]; !ok {
|
||||||
|
return errors.New("invalid section, please try again")
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("Please enter the stanza (either: \"gaia\", \"gaiacli\", \"gaiarest\", \"sdk\", \"tendermint\")")
|
||||||
|
stanzaDir, _ := reader.ReadString('\n')
|
||||||
|
stanzaDir = strings.TrimSpace(stanzaDir)
|
||||||
|
if _, ok := stanzas[stanzaDir]; !ok {
|
||||||
|
return errors.New("invalid stanza, please try again")
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("Please enter the changelog message (or press enter to write in default $EDITOR)")
|
||||||
|
message, _ := reader.ReadString('\n')
|
||||||
|
message = strings.TrimSpace(message)
|
||||||
|
if message == "" {
|
||||||
|
return addEntryFile(sectionDir, stanzaDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
return addSinglelineEntryFile(sectionDir, stanzaDir, message)
|
||||||
|
}
|
||||||
|
|
||||||
|
func addSinglelineEntryFile(sectionDir, stanzaDir, message string) error {
|
||||||
filename := filepath.Join(
|
filename := filepath.Join(
|
||||||
filepath.Join(entriesDir, sectionDir, stanzaDir),
|
filepath.Join(entriesDir, sectionDir, stanzaDir),
|
||||||
generateFileName(message),
|
generateFileName(message),
|
||||||
)
|
)
|
||||||
|
|
||||||
writeEntryFile(filename, []byte(message))
|
return writeEntryFile(filename, []byte(message))
|
||||||
}
|
}
|
||||||
|
|
||||||
func addEntryFile(sectionDir, stanzaDir string) {
|
func addEntryFile(sectionDir, stanzaDir string) error {
|
||||||
bs := readUserInput()
|
bs, err := readUserInputFromEditor()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
firstLine := strings.TrimSpace(strings.Split(string(bs), "\n")[0])
|
firstLine := strings.TrimSpace(strings.Split(string(bs), "\n")[0])
|
||||||
filename := filepath.Join(
|
filename := filepath.Join(
|
||||||
filepath.Join(entriesDir, sectionDir, stanzaDir),
|
filepath.Join(entriesDir, sectionDir, stanzaDir),
|
||||||
generateFileName(firstLine),
|
generateFileName(firstLine),
|
||||||
)
|
)
|
||||||
|
|
||||||
writeEntryFile(filename, bs)
|
return writeEntryFile(filename, bs)
|
||||||
}
|
}
|
||||||
|
|
||||||
var filenameInvalidChars = regexp.MustCompile(`[^a-zA-Z0-9-_]`)
|
|
||||||
|
|
||||||
func generateFileName(line string) string {
|
func generateFileName(line string) string {
|
||||||
var chunks []string
|
var chunks []string
|
||||||
|
|
||||||
|
filenameInvalidChars := regexp.MustCompile(`[^a-zA-Z0-9-_]`)
|
||||||
subsWithInvalidCharsRemoved := strings.Split(filenameInvalidChars.ReplaceAllString(line, " "), " ")
|
subsWithInvalidCharsRemoved := strings.Split(filenameInvalidChars.ReplaceAllString(line, " "), " ")
|
||||||
for _, sub := range subsWithInvalidCharsRemoved {
|
for _, sub := range subsWithInvalidCharsRemoved {
|
||||||
sub = strings.TrimSpace(sub)
|
sub = strings.TrimSpace(sub)
|
||||||
|
@ -140,17 +211,21 @@ func generateFileName(line string) string {
|
||||||
}
|
}
|
||||||
|
|
||||||
ret := strings.Join(chunks, "-")
|
ret := strings.Join(chunks, "-")
|
||||||
return ret[:int(math.Min(float64(len(ret)), float64(maxEntryFilenameLength)))]
|
|
||||||
|
if len(ret) > maxEntryFilenameLength {
|
||||||
|
return ret[:maxEntryFilenameLength]
|
||||||
|
}
|
||||||
|
return ret
|
||||||
}
|
}
|
||||||
|
|
||||||
func directoryContents(dirPath string) []os.FileInfo {
|
func directoryContents(dirPath string) ([]os.FileInfo, error) {
|
||||||
contents, err := ioutil.ReadDir(dirPath)
|
contents, err := ioutil.ReadDir(dirPath)
|
||||||
if err != nil && !os.IsNotExist(err) {
|
if err != nil && !os.IsNotExist(err) {
|
||||||
log.Fatalf("couldn't read directory %s: %v", dirPath, err)
|
return nil, fmt.Errorf("couldn't read directory %s: %v", dirPath, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(contents) == 0 {
|
if len(contents) == 0 {
|
||||||
return nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filter out hidden files
|
// Filter out hidden files
|
||||||
|
@ -164,16 +239,19 @@ func directoryContents(dirPath string) []os.FileInfo {
|
||||||
contents[i] = nil
|
contents[i] = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
return newContents
|
return newContents, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func generateChangelog(version string) {
|
func generateChangelog(version string) error {
|
||||||
fmt.Printf("# %s\n\n", version)
|
fmt.Printf("# %s\n\n", version)
|
||||||
for sectionDir, sectionTitle := range sections {
|
for sectionDir, sectionTitle := range sections {
|
||||||
sectionTitlePrinted := false
|
sectionTitlePrinted := false
|
||||||
for stanzaDir, stanzaTitle := range stanzas {
|
for stanzaDir, stanzaTitle := range stanzas {
|
||||||
path := filepath.Join(entriesDir, sectionDir, stanzaDir)
|
path := filepath.Join(entriesDir, sectionDir, stanzaDir)
|
||||||
files := directoryContents(path)
|
files, err := directoryContents(path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
if len(files) == 0 {
|
if len(files) == 0 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
@ -188,22 +266,27 @@ func generateChangelog(version string) {
|
||||||
verboseLog.Println("processing", f.Name())
|
verboseLog.Println("processing", f.Name())
|
||||||
filename := filepath.Join(path, f.Name())
|
filename := filepath.Join(path, f.Name())
|
||||||
if err := indentAndPrintFile(filename); err != nil {
|
if err := indentAndPrintFile(filename); err != nil {
|
||||||
log.Fatal(err)
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Println()
|
fmt.Println()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func pruneEmptyDirectories() {
|
func pruneEmptyDirectories() error {
|
||||||
for sectionDir := range sections {
|
for sectionDir := range sections {
|
||||||
for stanzaDir := range stanzas {
|
for stanzaDir := range stanzas {
|
||||||
mustPruneDirIfEmpty(filepath.Join(entriesDir, sectionDir, stanzaDir))
|
err := mustPruneDirIfEmpty(filepath.Join(entriesDir, sectionDir, stanzaDir))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
mustPruneDirIfEmpty(filepath.Join(entriesDir, sectionDir))
|
return mustPruneDirIfEmpty(filepath.Join(entriesDir, sectionDir))
|
||||||
}
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// nolint: errcheck
|
// nolint: errcheck
|
||||||
|
@ -238,45 +321,47 @@ func indentAndPrintFile(filename string) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
// nolint: errcheck
|
// nolint: errcheck
|
||||||
func writeEntryFile(filename string, bs []byte) {
|
func writeEntryFile(filename string, bs []byte) error {
|
||||||
if err := os.MkdirAll(filepath.Dir(filename), 0755); err != nil {
|
if err := os.MkdirAll(filepath.Dir(filename), 0755); err != nil {
|
||||||
log.Fatal(err)
|
return err
|
||||||
}
|
}
|
||||||
outFile, err := os.OpenFile(filename, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0644)
|
outFile, err := os.OpenFile(filename, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0644)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal(err)
|
return err
|
||||||
}
|
}
|
||||||
defer outFile.Close()
|
defer outFile.Close()
|
||||||
|
|
||||||
if _, err := outFile.Write(bs); err != nil {
|
if _, err := outFile.Write(bs); err != nil {
|
||||||
log.Fatal(err)
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Printf("Unreleased changelog entry written to: %s\n", filename)
|
log.Printf("Unreleased changelog entry written to: %s\n", filename)
|
||||||
log.Println("To modify this entry please edit or delete the above file directly.")
|
log.Println("To modify this entry please edit or delete the above file directly.")
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func validateSectionStanzaDirs(sectionDir, stanzaDir string) {
|
func validateSectionStanzaDirs(sectionDir, stanzaDir string) error {
|
||||||
if _, ok := sections[sectionDir]; !ok {
|
if _, ok := sections[sectionDir]; !ok {
|
||||||
log.Fatalf("invalid section -- %s", sectionDir)
|
return fmt.Errorf("invalid section -- %s", sectionDir)
|
||||||
}
|
}
|
||||||
if _, ok := stanzas[stanzaDir]; !ok {
|
if _, ok := stanzas[stanzaDir]; !ok {
|
||||||
log.Fatalf("invalid stanza -- %s", stanzaDir)
|
return fmt.Errorf("invalid stanza -- %s", stanzaDir)
|
||||||
}
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// nolint: errcheck
|
// nolint: errcheck
|
||||||
func readUserInput() []byte {
|
func readUserInputFromEditor() ([]byte, error) {
|
||||||
tempfilename, err := launchUserEditor()
|
tempfilename, err := launchUserEditor()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("couldn't open an editor: %v", err)
|
return []byte{}, fmt.Errorf("couldn't open an editor: %v", err)
|
||||||
}
|
}
|
||||||
defer os.Remove(tempfilename)
|
defer os.Remove(tempfilename)
|
||||||
bs, err := ioutil.ReadFile(tempfilename)
|
bs, err := ioutil.ReadFile(tempfilename)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("error: %v", err)
|
return []byte{}, fmt.Errorf("error: %v", err)
|
||||||
}
|
}
|
||||||
return bs
|
return bs, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// nolint: errcheck
|
// nolint: errcheck
|
||||||
|
@ -317,62 +402,28 @@ func launchUserEditor() (string, error) {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
if fileInfo.Size() == 0 {
|
if fileInfo.Size() == 0 {
|
||||||
log.Fatal("aborting due to empty message")
|
return "", errors.New("aborting due to empty message")
|
||||||
}
|
}
|
||||||
|
|
||||||
return tempfilename, nil
|
return tempfilename, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func mustPruneDirIfEmpty(path string) {
|
func mustPruneDirIfEmpty(path string) error {
|
||||||
if contents := directoryContents(path); len(contents) == 0 {
|
contents, err := directoryContents(path)
|
||||||
if err := os.Remove(path); err != nil {
|
if err != nil {
|
||||||
if !os.IsNotExist(err) {
|
return err
|
||||||
log.Fatal(err)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
log.Println(path, "removed")
|
|
||||||
}
|
}
|
||||||
}
|
if len(contents) != 0 {
|
||||||
|
return nil
|
||||||
func printUsage() {
|
}
|
||||||
usageText := fmt.Sprintf(`usage: %s [-d directory] [-v] command
|
if err := os.Remove(path); err != nil {
|
||||||
|
if !os.IsNotExist(err) {
|
||||||
Maintain unreleased changelog entries in a modular fashion.
|
return err
|
||||||
|
}
|
||||||
Commands:
|
return nil
|
||||||
add [section] [stanza] [message] Add an entry file. If message is empty, start
|
}
|
||||||
the editor to edit the message.
|
log.Println(path, "removed")
|
||||||
generate [version] Generate a changelog in Markdown format and print
|
return nil
|
||||||
it to STDOUT. version defaults to UNRELEASED.
|
|
||||||
prune Delete empty sub-directories recursively.
|
|
||||||
|
|
||||||
Sections Stanzas
|
|
||||||
--- ---
|
|
||||||
breaking gaia
|
|
||||||
features gaiacli
|
|
||||||
improvements gaiarest
|
|
||||||
bugfixes sdk
|
|
||||||
tendermint
|
|
||||||
`, progName)
|
|
||||||
fmt.Fprintf(os.Stderr, "%s\nFlags:\n", usageText)
|
|
||||||
flag.PrintDefaults()
|
|
||||||
}
|
|
||||||
|
|
||||||
func errInsufficientArgs() {
|
|
||||||
log.Println("insufficient arguments")
|
|
||||||
printUsage()
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
func errTooManyArgs() {
|
|
||||||
log.Println("too many arguments")
|
|
||||||
printUsage()
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
func unknownCommand(cmd string) {
|
|
||||||
log.Fatalf("unknown command -- '%s'\nTry '%s -help' for more information.", cmd, progName)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// DONTCOVER
|
// DONTCOVER
|
||||||
|
|
Loading…
Reference in New Issue