diff --git a/cmd/close.go b/cmd/close.go index f08491b..3171578 100644 --- a/cmd/close.go +++ b/cmd/close.go @@ -30,6 +30,27 @@ var closeCmd = &cobra.Command{ return nil } + addComment, _ := cmd.Flags().GetBool("comment") + if addComment { + body, err := draftCommentBody() + if err != nil { + return err + } + if body == "" { + fmt.Println(color.YellowString("Aborted.") + " Empty body, comment discarded.") + return nil + } + commentsPath := filepath.Join(filepath.Dir(iss.Path), issue.CommentsFilename(iss)) + comments, err := issue.ParseComments(commentsPath) + if err != nil { + return fmt.Errorf("reading comments: %w", err) + } + comments = append(comments, &issue.Comment{Body: body}) + if err := issue.WriteComments(commentsPath, comments); err != nil { + return fmt.Errorf("saving comment: %w", err) + } + } + iss.State = "closed" newPath := filepath.Join(closedDir(root), filepath.Base(iss.Path)) @@ -43,7 +64,19 @@ var closeCmd = &cobra.Command{ return err } + oldComments := filepath.Join(filepath.Dir(iss.Path), issue.CommentsFilename(iss)) + if _, statErr := os.Stat(oldComments); statErr == nil { + newComments := filepath.Join(closedDir(root), issue.CommentsFilename(iss)) + if err := os.Rename(oldComments, newComments); err != nil { + return err + } + } + fmt.Printf("%s %s: %s\n", color.GreenString("Closed"), args[0], iss.Title) return nil }, } + +func init() { + closeCmd.Flags().BoolP("comment", "c", false, "open editor to draft a closing comment") +} diff --git a/cmd/close_test.go b/cmd/close_test.go index 7596efd..04bb3d5 100644 --- a/cmd/close_test.go +++ b/cmd/close_test.go @@ -8,6 +8,18 @@ import ( "github.com/jamesjohnsdev/issues/internal/issue" ) +func setCloseComment(t *testing.T, val bool) { + t.Helper() + v := "false" + if val { + v = "true" + } + if err := closeCmd.Flags().Set("comment", v); err != nil { + t.Fatalf("setting --comment flag: %v", err) + } + t.Cleanup(func() { _ = closeCmd.Flags().Set("comment", "false") }) +} + func TestClose(t *testing.T) { tests := []struct { name string @@ -112,3 +124,105 @@ func TestClose(t *testing.T) { }) } } + +func TestCloseMovesCommentsFile(t *testing.T) { + parent := makeProjectDir(t, []issueFixture{ + {"1-my-issue.md", issue.Issue{Number: 1, Title: "My issue", State: "open"}}, + }) + chdirTo(t, parent) + + commentsFile := filepath.Join(parent, issuesDirName, "open", "1-my-issue.comments.json") + if err := issue.WriteComments(commentsFile, []*issue.Comment{{Body: "existing"}}); err != nil { + t.Fatal(err) + } + + _ = captureStdout(t, func() { + if err := closeCmd.RunE(closeCmd, []string{"1"}); err != nil { + t.Fatalf("unexpected error: %v", err) + } + }) + + closedComments := filepath.Join(parent, issuesDirName, "closed", "1-my-issue.comments.json") + if _, err := os.Stat(closedComments); os.IsNotExist(err) { + t.Error("expected comments file to be moved to closed/, but it was not found there") + } + if _, err := os.Stat(commentsFile); !os.IsNotExist(err) { + t.Error("expected comments file to be gone from open/, but it still exists") + } +} + +func TestCloseWithComment(t *testing.T) { + t.Run("no editor returns error", func(t *testing.T) { + parent := makeProjectDir(t, []issueFixture{ + {"1-my-issue.md", issue.Issue{Number: 1, Title: "My issue", State: "open"}}, + }) + chdirTo(t, parent) + t.Setenv("VISUAL", "") + t.Setenv("EDITOR", "") + setCloseComment(t, true) + + err := closeCmd.RunE(closeCmd, []string{"1"}) + if err == nil { + t.Error("expected error when no editor set, got nil") + } + }) + + t.Run("empty body aborts without closing", func(t *testing.T) { + parent := makeProjectDir(t, []issueFixture{ + {"1-my-issue.md", issue.Issue{Number: 1, Title: "My issue", State: "open"}}, + }) + chdirTo(t, parent) + t.Setenv("VISUAL", "") + t.Setenv("EDITOR", "true") + setCloseComment(t, true) + + _ = captureStdout(t, func() { + if err := closeCmd.RunE(closeCmd, []string{"1"}); err != nil { + t.Fatalf("unexpected error: %v", err) + } + }) + + openPath := filepath.Join(parent, issuesDirName, "open", "1-my-issue.md") + if _, err := os.Stat(openPath); os.IsNotExist(err) { + t.Error("issue should not have been closed when comment body was empty") + } + }) + + t.Run("comment saved and issue closed", func(t *testing.T) { + parent := makeProjectDir(t, []issueFixture{ + {"1-my-issue.md", issue.Issue{Number: 1, Title: "My issue", State: "open"}}, + }) + chdirTo(t, parent) + + script := filepath.Join(t.TempDir(), "editor.sh") + if err := os.WriteFile(script, []byte("#!/bin/sh\necho 'closing note' > \"$1\"\n"), 0755); err != nil { + t.Fatal(err) + } + t.Setenv("VISUAL", "") + t.Setenv("EDITOR", script) + setCloseComment(t, true) + + _ = captureStdout(t, func() { + if err := closeCmd.RunE(closeCmd, []string{"1"}); err != nil { + t.Fatalf("unexpected error: %v", err) + } + }) + + closedPath := filepath.Join(parent, issuesDirName, "closed", "1-my-issue.md") + if _, err := os.Stat(closedPath); os.IsNotExist(err) { + t.Error("expected issue in closed/ but it was not found") + } + + commentsPath := filepath.Join(parent, issuesDirName, "closed", "1-my-issue.comments.json") + comments, err := issue.ParseComments(commentsPath) + if err != nil { + t.Fatalf("ParseComments: %v", err) + } + if len(comments) != 1 { + t.Fatalf("got %d comments, want 1", len(comments)) + } + if comments[0].Body != "closing note" { + t.Errorf("Body = %q, want %q", comments[0].Body, "closing note") + } + }) +} diff --git a/cmd/comment.go b/cmd/comment.go index 0a3af58..730a7d0 100644 --- a/cmd/comment.go +++ b/cmd/comment.go @@ -2,10 +2,7 @@ package cmd import ( "fmt" - "os" - "os/exec" "path/filepath" - "strings" "github.com/fatih/color" "github.com/jamesjohnsdev/issues/internal/issue" @@ -27,46 +24,10 @@ var commentCmd = &cobra.Command{ return err } - editor := os.Getenv("VISUAL") - if editor == "" { - editor = os.Getenv("EDITOR") - } - if editor == "" { - return fmt.Errorf("no editor set: define $VISUAL or $EDITOR") - } - - tmp, err := os.CreateTemp("", "issues-comment-*.md") + body, err := draftCommentBody() if err != nil { - return fmt.Errorf("creating temp file: %w", err) - } - tmpPath := tmp.Name() - var funcErr error = nil - defer func() { - if err := os.Remove(tmpPath); err != nil { - funcErr = err - } - }() - if funcErr != nil { - return fmt.Errorf("removing temp file: %w", funcErr) - } - if err := tmp.Close(); err != nil { - return fmt.Errorf("closing temp file: %w", err) - } - - parts := strings.Fields(editor) - c := exec.Command(parts[0], append(parts[1:], tmpPath)...) - c.Stdin = os.Stdin - c.Stdout = os.Stdout - c.Stderr = os.Stderr - if err := c.Run(); err != nil { - return fmt.Errorf("editor exited with error: %w", err) - } - - data, err := os.ReadFile(tmpPath) - if err != nil { - return fmt.Errorf("reading temp file: %w", err) + return err } - body := strings.TrimSpace(string(data)) if body == "" { fmt.Println(color.YellowString("Aborted.") + " Empty body, comment discarded.") return nil diff --git a/cmd/list.go b/cmd/list.go index f605f88..5b021e0 100644 --- a/cmd/list.go +++ b/cmd/list.go @@ -2,6 +2,8 @@ package cmd import ( "fmt" + "os" + "os/exec" "sort" "strings" @@ -10,12 +12,20 @@ import ( "github.com/spf13/cobra" ) -var listAll, listClosed bool +var listAll, listClosed, listWeb bool var listCmd = &cobra.Command{ Use: "list", Short: "List local issues", RunE: func(cmd *cobra.Command, args []string) error { + if listWeb { + c := exec.Command("gh", "issue", "list", "--web") + c.Stdin = os.Stdin + c.Stdout = os.Stdout + c.Stderr = os.Stderr + return c.Run() + } + root, err := issuesRoot() if err != nil { return err @@ -107,4 +117,5 @@ var listCmd = &cobra.Command{ func init() { listCmd.Flags().BoolVar(&listAll, "all", false, "show open and closed issues") listCmd.Flags().BoolVar(&listClosed, "closed", false, "show only closed issues") + listCmd.Flags().BoolVarP(&listWeb, "web", "w", false, "open issues list in the browser") } diff --git a/cmd/util.go b/cmd/util.go index c407186..f42c68f 100644 --- a/cmd/util.go +++ b/cmd/util.go @@ -4,6 +4,7 @@ import ( "errors" "fmt" "os" + "os/exec" "path/filepath" "strconv" "strings" @@ -123,6 +124,54 @@ func findLocalByID(root, id string) (*issue.Issue, error) { return nil, fmt.Errorf("issue #%d not found locally", number) } +// draftCommentBody opens $VISUAL or $EDITOR for the user to write a comment and returns the trimmed body. +// An empty string means the user saved without writing anything. +func draftCommentBody() (string, error) { + editor := os.Getenv("VISUAL") + if editor == "" { + editor = os.Getenv("EDITOR") + } + if editor == "" { + return "", fmt.Errorf("no editor set: define $VISUAL or $EDITOR") + } + + tmp, err := os.CreateTemp("", "issues-comment-*.md") + if err != nil { + return "", fmt.Errorf("creating temp file: %w", err) + } + tmpPath := tmp.Name() + var funcErr error + defer func() { + if err := os.Remove(tmpPath); err != nil { + funcErr = fmt.Errorf("removing temp file: %w", err) + } + }() + if funcErr != nil { + return "", funcErr + } + if err := tmp.Close(); err != nil { + return "", fmt.Errorf("closing temp file: %w", err) + } + + parts := strings.Fields(editor) + if len(parts) == 0 { + return "", fmt.Errorf("no editor set: define $VISUAL or $EDITOR") + } + c := exec.Command(parts[0], append(parts[1:], tmpPath)...) + c.Stdin = os.Stdin + c.Stdout = os.Stdout + c.Stderr = os.Stderr + if err := c.Run(); err != nil { + return "", fmt.Errorf("editor exited with error: %w", err) + } + + data, err := os.ReadFile(tmpPath) + if err != nil { + return "", fmt.Errorf("reading temp file: %w", err) + } + return strings.TrimSpace(string(data)), nil +} + // idFromPath extracts the ID prefix ("T1" or "42") from a filename like "T1-my-issue.md". func idFromPath(path string) string { base := strings.TrimSuffix(filepath.Base(path), ".md") diff --git a/cmd/util_test.go b/cmd/util_test.go index 76589e0..1a0dc0c 100644 --- a/cmd/util_test.go +++ b/cmd/util_test.go @@ -144,6 +144,18 @@ func captureStdout(t *testing.T, fn func()) string { return buf.String() } +func TestDraftCommentBodyWhitespaceEditor(t *testing.T) { + t.Run("whitespace-only EDITOR returns error instead of panic", func(t *testing.T) { + t.Setenv("VISUAL", "") + t.Setenv("EDITOR", " ") + + _, err := draftCommentBody() + if err == nil { + t.Error("expected error for whitespace-only EDITOR, got nil") + } + }) +} + func TestIDFromPath(t *testing.T) { tests := []struct { path string diff --git a/cmd/view.go b/cmd/view.go index 08bda43..ee09ef0 100644 --- a/cmd/view.go +++ b/cmd/view.go @@ -14,6 +14,7 @@ import ( ) var viewCommentsFlag bool +var viewWebFlag bool var viewCmd = &cobra.Command{ Use: "view ", @@ -30,6 +31,21 @@ var viewCmd = &cobra.Command{ return err } + if viewWebFlag && viewCommentsFlag { + return fmt.Errorf("--web and --comments are mutually exclusive") + } + + if viewWebFlag { + if iss.Number == 0 { + return fmt.Errorf("issue %s is local-only and has no GitHub URL", idFromPath(iss.Path)) + } + c := exec.Command("gh", "issue", "view", fmt.Sprintf("%d", iss.Number), "--web") + c.Stdin = os.Stdin + c.Stdout = os.Stdout + c.Stderr = os.Stderr + return c.Run() + } + if viewCommentsFlag { return printComments(iss) } @@ -43,6 +59,9 @@ var viewCmd = &cobra.Command{ } parts := strings.Fields(editor) + if len(parts) == 0 { + return fmt.Errorf("no editor set: define $VISUAL or $EDITOR") + } c := exec.Command(parts[0], append(parts[1:], iss.Path)...) c.Stdin = os.Stdin c.Stdout = os.Stdout @@ -87,4 +106,5 @@ func printComments(iss *issue.Issue) error { func init() { viewCmd.Flags().BoolVarP(&viewCommentsFlag, "comments", "c", false, "show comments instead of opening the editor") + viewCmd.Flags().BoolVarP(&viewWebFlag, "web", "w", false, "open the issue in the browser") } diff --git a/cmd/view_test.go b/cmd/view_test.go index 63d40a6..3cd8e4e 100644 --- a/cmd/view_test.go +++ b/cmd/view_test.go @@ -11,9 +11,14 @@ import ( func resetViewFlags(t *testing.T) { t.Helper() - orig := viewCommentsFlag - t.Cleanup(func() { viewCommentsFlag = orig }) + origComments := viewCommentsFlag + origWeb := viewWebFlag + t.Cleanup(func() { + viewCommentsFlag = origComments + viewWebFlag = origWeb + }) viewCommentsFlag = false + viewWebFlag = false } func TestViewComments(t *testing.T) { @@ -162,3 +167,57 @@ func TestViewComments(t *testing.T) { } }) } + +func TestViewWeb(t *testing.T) { + t.Run("web flag on local T-issue returns error", func(t *testing.T) { + parent := makeProjectDir(t, []issueFixture{ + {"T1-local-only.md", issue.Issue{Number: 0, Title: "Local only", State: "open"}}, + }) + chdirTo(t, parent) + resetViewFlags(t) + viewWebFlag = true + + err := viewCmd.RunE(viewCmd, []string{"T1"}) + if err == nil { + t.Error("expected error for local-only issue with --web, got nil") + } + if !strings.Contains(err.Error(), "local-only") { + t.Errorf("expected 'local-only' in error, got: %v", err) + } + }) + + t.Run("web and comments flags are mutually exclusive", func(t *testing.T) { + parent := makeProjectDir(t, []issueFixture{ + {"1-my-issue.md", issue.Issue{Number: 1, Title: "My issue", State: "open"}}, + }) + chdirTo(t, parent) + resetViewFlags(t) + viewWebFlag = true + viewCommentsFlag = true + + err := viewCmd.RunE(viewCmd, []string{"1"}) + if err == nil { + t.Error("expected error when both --web and --comments are set, got nil") + } + if !strings.Contains(err.Error(), "mutually exclusive") { + t.Errorf("expected 'mutually exclusive' in error, got: %v", err) + } + }) +} + +func TestViewWhitespaceEditor(t *testing.T) { + t.Run("whitespace-only EDITOR returns error instead of panic", func(t *testing.T) { + parent := makeProjectDir(t, []issueFixture{ + {"1-my-issue.md", issue.Issue{Number: 1, Title: "My issue", State: "open"}}, + }) + chdirTo(t, parent) + resetViewFlags(t) + t.Setenv("VISUAL", "") + t.Setenv("EDITOR", " ") + + err := viewCmd.RunE(viewCmd, []string{"1"}) + if err == nil { + t.Error("expected error for whitespace-only EDITOR, got nil") + } + }) +} diff --git a/release-please-config.json b/release-please-config.json index ff6bc78..3d935bb 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -1,7 +1,6 @@ { "release-type": "go", "bump-minor-pre-major": true, - "bump-patch-for-minor-pre-major": true, "packages": { ".": {} }