Skip to content
Merged

dev #41

Show file tree
Hide file tree
Changes from all 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
33 changes: 33 additions & 0 deletions cmd/close.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand All @@ -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")
}
114 changes: 114 additions & 0 deletions cmd/close_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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")
}
})
}
43 changes: 2 additions & 41 deletions cmd/comment.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,7 @@ package cmd

import (
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"

"github.com/fatih/color"
"github.com/jamesjohnsdev/issues/internal/issue"
Expand All @@ -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
Expand Down
13 changes: 12 additions & 1 deletion cmd/list.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ package cmd

import (
"fmt"
"os"
"os/exec"
"sort"
"strings"

Expand All @@ -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
Expand Down Expand Up @@ -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")
}
49 changes: 49 additions & 0 deletions cmd/util.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
Expand Down Expand Up @@ -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)...)
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
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")
Expand Down
12 changes: 12 additions & 0 deletions cmd/util_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
20 changes: 20 additions & 0 deletions cmd/view.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import (
)

var viewCommentsFlag bool
var viewWebFlag bool

var viewCmd = &cobra.Command{
Use: "view <number>",
Expand All @@ -30,6 +31,21 @@ var viewCmd = &cobra.Command{
return err
}

if viewWebFlag && viewCommentsFlag {
return fmt.Errorf("--web and --comments are mutually exclusive")
}

if viewWebFlag {
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
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)
}
Expand All @@ -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
Expand Down Expand Up @@ -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")
}
Loading