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 {