From 7d8c141631a38f62d345e398384f7da6d1367240 Mon Sep 17 00:00:00 2001 From: James Johns <192176108+jamesjohnsdev@users.noreply.github.com> Date: Thu, 18 Jun 2026 14:16:18 +1000 Subject: [PATCH] feat(create): add stepwise interactive prompts when no args given Mirrors UX: prompts for title, body editor, labels, assignees, and milestone in sequence. Empty or whitespace-only title aborts without creating a file. Also adds injectStdin/readMDFiles test helpers, TestCreateInteractive (9 subtests), and FuzzCreateInteractive covering all prompt fields. --- cmd/create.go | 107 +++++++++++++--- cmd/create_test.go | 300 +++++++++++++++++++++++++++++++++++++++++++++ cmd/fuzz_test.go | 62 ++++++++++ cmd/util_test.go | 40 ++++++ 4 files changed, 491 insertions(+), 18 deletions(-) diff --git a/cmd/create.go b/cmd/create.go index 3de9d40..edea29c 100644 --- a/cmd/create.go +++ b/cmd/create.go @@ -1,6 +1,7 @@ package cmd import ( + "bufio" "fmt" "os" "os/exec" @@ -15,13 +16,16 @@ import ( var createEditorFlag bool var createCmd = &cobra.Command{ - Use: "create ", + Use: "create [title]", Short: "Create a new local issue", Args: func(cmd *cobra.Command, args []string) error { if createEditorFlag { return cobra.NoArgs(cmd, args) } - return cobra.ExactArgs(1)(cmd, args) + if len(args) > 1 { + return fmt.Errorf("accepts at most 1 arg, received %d", len(args)) + } + return nil }, RunE: func(cmd *cobra.Command, args []string) error { root, err := issuesRoot() @@ -46,31 +50,98 @@ var createCmd = &cobra.Command{ } } - var title, filename string - if createEditorFlag { - title = "" + editor := os.Getenv("VISUAL") + if editor == "" { + editor = os.Getenv("EDITOR") + } + + interactive := !createEditorFlag && len(args) == 0 + + var filename string + var openEditor bool + iss := &issue.Issue{State: "open"} + + switch { + case createEditorFlag: filename = fmt.Sprintf("T%d-new-issue.md", maxT+1) - } else { - title = args[0] - filename = fmt.Sprintf("T%d-%s.md", maxT+1, issue.Slug(title)) + openEditor = true + if editor == "" { + return fmt.Errorf("no editor set: define $VISUAL or $EDITOR") + } + + case interactive: + reader := bufio.NewReader(os.Stdin) + + prompt := func(label string) (string, error) { + fmt.Print(label) + line, err := reader.ReadString('\n') + return strings.TrimSpace(line), err + } + + iss.Title, err = prompt("Title: ") + if err != nil { + return err + } + if iss.Title == "" { + fmt.Println(color.YellowString("Aborted.") + " No title, issue discarded.") + return nil + } + filename = fmt.Sprintf("T%d-%s.md", maxT+1, issue.Slug(iss.Title)) + + var bodyPrompt string + if editor != "" { + bodyPrompt = fmt.Sprintf("Body [(e) to launch %s, Enter to skip]: ", filepath.Base(editor)) + } else { + bodyPrompt = "Body [Enter to skip]: " + } + bodyResp, err := prompt(bodyPrompt) + if err != nil { + return err + } + openEditor = strings.ToLower(bodyResp) == "e" + if openEditor && editor == "" { + fmt.Println(color.YellowString("No editor set") + " ($VISUAL/$EDITOR). Skipping body.") + openEditor = false + } + + labelsResp, err := prompt("Labels (comma-separated, Enter to skip): ") + if err != nil { + return err + } + for _, l := range strings.Split(labelsResp, ",") { + if l := strings.TrimSpace(l); l != "" { + iss.Labels = append(iss.Labels, l) + } + } + + assigneesResp, err := prompt("Assignees (comma-separated, Enter to skip): ") + if err != nil { + return err + } + for _, a := range strings.Split(assigneesResp, ",") { + if a := strings.TrimSpace(a); a != "" { + iss.Assignees = append(iss.Assignees, a) + } + } + + iss.Milestone, err = prompt("Milestone (Enter to skip): ") + if err != nil { + return err + } + + default: + iss.Title = args[0] + filename = fmt.Sprintf("T%d-%s.md", maxT+1, issue.Slug(iss.Title)) + openEditor = editor != "" } - iss := &issue.Issue{Title: title, State: "open"} path := filepath.Join(openDir(root), filename) if err := issue.Write(path, iss); err != nil { return err } - editor := os.Getenv("VISUAL") - if editor == "" { - editor = os.Getenv("EDITOR") - } - if createEditorFlag && editor == "" { - _ = os.Remove(path) - return fmt.Errorf("no editor set: define $VISUAL or $EDITOR") - } - if editor != "" { + if openEditor { parts := strings.Fields(editor) c := exec.Command(parts[0], append(parts[1:], path)...) c.Stdin = os.Stdin diff --git a/cmd/create_test.go b/cmd/create_test.go index 8570824..2303490 100644 --- a/cmd/create_test.go +++ b/cmd/create_test.go @@ -3,9 +3,309 @@ package cmd import ( "os" "path/filepath" + "strings" "testing" + + "github.com/jamesjohnsdev/issues/internal/issue" ) +func resetCreateFlag(t *testing.T) { + t.Helper() + orig := createEditorFlag + t.Cleanup(func() { createEditorFlag = orig }) + createEditorFlag = false +} + +func openIssuesDir(t *testing.T, parent string) string { + t.Helper() + return filepath.Join(parent, issuesDirName, "open") +} + +func TestCreateInteractive(t *testing.T) { + t.Run("empty title aborts with no file", func(t *testing.T) { + parent := makeProjectDir(t, nil) + chdirTo(t, parent) + resetCreateFlag(t) + t.Setenv("VISUAL", "") + t.Setenv("EDITOR", "") + + injectStdin(t, "\n\n\n\n\n") + _ = captureStdout(t, func() { + if err := createCmd.RunE(createCmd, nil); err != nil { + t.Fatalf("unexpected error: %v", err) + } + }) + + if files := readMDFiles(t, openIssuesDir(t, parent)); len(files) != 0 { + t.Errorf("expected no files after empty title, got %d", len(files)) + } + }) + + t.Run("whitespace-only title aborts", func(t *testing.T) { + parent := makeProjectDir(t, nil) + chdirTo(t, parent) + resetCreateFlag(t) + t.Setenv("VISUAL", "") + t.Setenv("EDITOR", "") + + injectStdin(t, " \n\n\n\n\n") + _ = captureStdout(t, func() { + if err := createCmd.RunE(createCmd, nil); err != nil { + t.Fatalf("unexpected error: %v", err) + } + }) + + if files := readMDFiles(t, openIssuesDir(t, parent)); len(files) != 0 { + t.Errorf("expected no files after whitespace title, got %d", len(files)) + } + }) + + t.Run("title only creates file", func(t *testing.T) { + parent := makeProjectDir(t, nil) + chdirTo(t, parent) + resetCreateFlag(t) + t.Setenv("VISUAL", "") + t.Setenv("EDITOR", "") + + injectStdin(t, "My Bug\n\n\n\n\n") + _ = captureStdout(t, func() { + if err := createCmd.RunE(createCmd, nil); err != nil { + t.Fatalf("unexpected error: %v", err) + } + }) + + files := readMDFiles(t, openIssuesDir(t, parent)) + if len(files) != 1 { + t.Fatalf("expected 1 file, got %d", len(files)) + } + iss, err := issue.Parse(files[0]) + if err != nil { + t.Fatalf("parse error: %v", err) + } + if iss.Title != "My Bug" { + t.Errorf("Title = %q, want %q", iss.Title, "My Bug") + } + if iss.State != "open" { + t.Errorf("State = %q, want %q", iss.State, "open") + } + if !strings.Contains(filepath.Base(files[0]), "my-bug") { + t.Errorf("filename %q should contain slug %q", filepath.Base(files[0]), "my-bug") + } + }) + + t.Run("labels parsed and written", func(t *testing.T) { + parent := makeProjectDir(t, nil) + chdirTo(t, parent) + resetCreateFlag(t) + t.Setenv("VISUAL", "") + t.Setenv("EDITOR", "") + + injectStdin(t, "Fix Auth\n\nbug, auth\n\n\n") + _ = captureStdout(t, func() { + if err := createCmd.RunE(createCmd, nil); err != nil { + t.Fatalf("unexpected error: %v", err) + } + }) + + files := readMDFiles(t, openIssuesDir(t, parent)) + if len(files) != 1 { + t.Fatalf("expected 1 file, got %d", len(files)) + } + iss, err := issue.Parse(files[0]) + if err != nil { + t.Fatalf("parse error: %v", err) + } + want := []string{"bug", "auth"} + if len(iss.Labels) != len(want) { + t.Fatalf("Labels = %v, want %v", iss.Labels, want) + } + for i, l := range want { + if iss.Labels[i] != l { + t.Errorf("Labels[%d] = %q, want %q", i, iss.Labels[i], l) + } + } + }) + + t.Run("assignees parsed and written", func(t *testing.T) { + parent := makeProjectDir(t, nil) + chdirTo(t, parent) + resetCreateFlag(t) + t.Setenv("VISUAL", "") + t.Setenv("EDITOR", "") + + injectStdin(t, "Fix Auth\n\n\njames, alice\n\n") + _ = captureStdout(t, func() { + if err := createCmd.RunE(createCmd, nil); err != nil { + t.Fatalf("unexpected error: %v", err) + } + }) + + files := readMDFiles(t, openIssuesDir(t, parent)) + if len(files) != 1 { + t.Fatalf("expected 1 file, got %d", len(files)) + } + iss, err := issue.Parse(files[0]) + if err != nil { + t.Fatalf("parse error: %v", err) + } + want := []string{"james", "alice"} + if len(iss.Assignees) != len(want) { + t.Fatalf("Assignees = %v, want %v", iss.Assignees, want) + } + for i, a := range want { + if iss.Assignees[i] != a { + t.Errorf("Assignees[%d] = %q, want %q", i, iss.Assignees[i], a) + } + } + }) + + t.Run("milestone set", func(t *testing.T) { + parent := makeProjectDir(t, nil) + chdirTo(t, parent) + resetCreateFlag(t) + t.Setenv("VISUAL", "") + t.Setenv("EDITOR", "") + + injectStdin(t, "Fix Auth\n\n\n\nv1.2\n") + _ = captureStdout(t, func() { + if err := createCmd.RunE(createCmd, nil); err != nil { + t.Fatalf("unexpected error: %v", err) + } + }) + + files := readMDFiles(t, openIssuesDir(t, parent)) + if len(files) != 1 { + t.Fatalf("expected 1 file, got %d", len(files)) + } + iss, err := issue.Parse(files[0]) + if err != nil { + t.Fatalf("parse error: %v", err) + } + if iss.Milestone != "v1.2" { + t.Errorf("Milestone = %q, want %q", iss.Milestone, "v1.2") + } + }) + + t.Run("all metadata fields", func(t *testing.T) { + parent := makeProjectDir(t, nil) + chdirTo(t, parent) + resetCreateFlag(t) + t.Setenv("VISUAL", "") + t.Setenv("EDITOR", "") + + injectStdin(t, "Full Issue\n\nbug\njames\nv2.0\n") + _ = captureStdout(t, func() { + if err := createCmd.RunE(createCmd, nil); err != nil { + t.Fatalf("unexpected error: %v", err) + } + }) + + files := readMDFiles(t, openIssuesDir(t, parent)) + if len(files) != 1 { + t.Fatalf("expected 1 file, got %d", len(files)) + } + iss, err := issue.Parse(files[0]) + if err != nil { + t.Fatalf("parse error: %v", err) + } + if iss.Title != "Full Issue" { + t.Errorf("Title = %q, want %q", iss.Title, "Full Issue") + } + if len(iss.Labels) != 1 || iss.Labels[0] != "bug" { + t.Errorf("Labels = %v, want [bug]", iss.Labels) + } + if len(iss.Assignees) != 1 || iss.Assignees[0] != "james" { + t.Errorf("Assignees = %v, want [james]", iss.Assignees) + } + if iss.Milestone != "v2.0" { + t.Errorf("Milestone = %q, want %q", iss.Milestone, "v2.0") + } + }) + + t.Run("body e with no editor skips and still creates file", func(t *testing.T) { + parent := makeProjectDir(t, nil) + chdirTo(t, parent) + resetCreateFlag(t) + t.Setenv("VISUAL", "") + t.Setenv("EDITOR", "") + + injectStdin(t, "My Bug\ne\n\n\n\n") + _ = captureStdout(t, func() { + if err := createCmd.RunE(createCmd, nil); err != nil { + t.Fatalf("unexpected error: %v", err) + } + }) + + if files := readMDFiles(t, openIssuesDir(t, parent)); len(files) != 1 { + t.Errorf("expected 1 file, got %d", len(files)) + } + }) + + t.Run("body e with editor writes body", func(t *testing.T) { + parent := makeProjectDir(t, nil) + chdirTo(t, parent) + resetCreateFlag(t) + + script := filepath.Join(t.TempDir(), "editor.sh") + if err := os.WriteFile(script, []byte( + "#!/bin/sh\ncat > \"$1\" <<'EOF'\n---\ntitle: My Bug\nstate: open\n---\n\nsome body\nEOF\n", + ), 0755); err != nil { + t.Fatal(err) + } + t.Setenv("VISUAL", "") + t.Setenv("EDITOR", script) + + injectStdin(t, "My Bug\ne\n\n\n\n") + _ = captureStdout(t, func() { + if err := createCmd.RunE(createCmd, nil); err != nil { + t.Fatalf("unexpected error: %v", err) + } + }) + + files := readMDFiles(t, openIssuesDir(t, parent)) + if len(files) != 1 { + t.Fatalf("expected 1 file, got %d", len(files)) + } + iss, err := issue.Parse(files[0]) + if err != nil { + t.Fatalf("parse error: %v", err) + } + if iss.Body == "" { + t.Error("expected non-empty body after editor wrote content") + } + }) + + t.Run("T-number increments from existing issues", func(t *testing.T) { + parent := makeProjectDir(t, []issueFixture{ + {"T1-existing.md", issue.Issue{Title: "Existing", State: "open"}}, + {"T2-another.md", issue.Issue{Title: "Another", State: "open"}}, + }) + chdirTo(t, parent) + resetCreateFlag(t) + t.Setenv("VISUAL", "") + t.Setenv("EDITOR", "") + + injectStdin(t, "New Issue\n\n\n\n\n") + _ = captureStdout(t, func() { + if err := createCmd.RunE(createCmd, nil); err != nil { + t.Fatalf("unexpected error: %v", err) + } + }) + + files := readMDFiles(t, openIssuesDir(t, parent)) + var newFile string + for _, f := range files { + base := filepath.Base(f) + if strings.HasPrefix(base, "T3-") { + newFile = f + } + } + if newFile == "" { + t.Errorf("expected a file prefixed T3-, got files: %v", files) + } + }) +} + func TestCreateEditorFlag(t *testing.T) { t.Run("no editor set returns error", func(t *testing.T) { parent := makeProjectDir(t, nil) diff --git a/cmd/fuzz_test.go b/cmd/fuzz_test.go index 293d238..84c99de 100644 --- a/cmd/fuzz_test.go +++ b/cmd/fuzz_test.go @@ -3,6 +3,7 @@ package cmd import ( "os" "path/filepath" + "strings" "testing" "github.com/jamesjohnsdev/issues/internal/issue" @@ -72,3 +73,64 @@ func FuzzFindLocalByID(f *testing.F) { } }) } + +func FuzzCreateInteractive(f *testing.F) { + f.Add("Fix the bug", "", "", "", "") + f.Add("Add feature", "e", "bug,feature", "alice", "v2.0") + f.Add("", "", "", "", "") + f.Add("title: with colon", "", "l1, l2", "a, b", "ms 1") + f.Add(strings.Repeat("x", 200), "", "label", "", "") + f.Add(" ", "", "", "", "") + f.Add("Nö ASCII", "", "", "", "") + f.Add("a", "e", "", "", "") + + f.Fuzz(func(t *testing.T, title, bodyResp, labels, assignees, milestone string) { + parent := makeProjectDir(t, nil) + chdirTo(t, parent) + + orig := createEditorFlag + t.Cleanup(func() { createEditorFlag = orig }) + createEditorFlag = false + + t.Setenv("VISUAL", "") + t.Setenv("EDITOR", "") + + // Strip embedded newlines from each field so they don't bleed into + // adjacent prompts. + clean := func(s string) string { return strings.ReplaceAll(s, "\n", " ") } + stdin := strings.Join([]string{ + clean(title), clean(bodyResp), clean(labels), clean(assignees), clean(milestone), + }, "\n") + "\n" + + injectStdin(t, stdin) + _ = captureStdout(t, func() { + _ = createCmd.RunE(createCmd, nil) + }) + + openDir := filepath.Join(parent, issuesDirName, "open") + files := readMDFiles(t, openDir) + + wantTitle := strings.TrimSpace(title) + if wantTitle == "" { + if len(files) != 0 { + t.Errorf("empty title produced %d files, want 0", len(files)) + } + return + } + + if len(files) != 1 { + t.Fatalf("title %q produced %d files, want 1", title, len(files)) + } + + iss, err := issue.Parse(files[0]) + if err != nil { + t.Fatalf("created file not parseable: %v", err) + } + if iss.Title != wantTitle { + t.Errorf("Title = %q, want %q", iss.Title, wantTitle) + } + if iss.State != "open" { + t.Errorf("State = %q, want open", iss.State) + } + }) +} diff --git a/cmd/util_test.go b/cmd/util_test.go index 6648edb..3167aba 100644 --- a/cmd/util_test.go +++ b/cmd/util_test.go @@ -82,6 +82,46 @@ func chdirTo(t *testing.T, dir string) { t.Cleanup(func() { _ = os.Chdir(orig) }) } +// injectStdin replaces os.Stdin with a reader containing content for the +// duration of the test. +func injectStdin(t *testing.T, content string) { + t.Helper() + r, w, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + if _, err := w.WriteString(content); err != nil { + t.Fatal(err) + } + if err := w.Close(); err != nil { + t.Fatal(err) + } + old := os.Stdin + os.Stdin = r + t.Cleanup(func() { + os.Stdin = old + if err := r.Close(); err != nil { + t.Errorf("injectStdin: close pipe: %v", err) + } + }) +} + +// readMDFiles returns absolute paths to all .md files directly inside dir. +func readMDFiles(t *testing.T, dir string) []string { + t.Helper() + entries, err := os.ReadDir(dir) + if err != nil { + t.Fatal(err) + } + var files []string + for _, e := range entries { + if !e.IsDir() && filepath.Ext(e.Name()) == ".md" { + files = append(files, filepath.Join(dir, e.Name())) + } + } + return files +} + // captureStdout runs fn and returns everything written to os.Stdout during its // execution. func captureStdout(t *testing.T, fn func()) string {