Skip to content
Merged

dev #37

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
22 changes: 22 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,32 @@ also for AI augmented workflows, where agents can have a source for development.
- `issues create <title>` - creates a new issue in the current directory.
- `issues create -e` - opens a blank issue in the editor without requiring a title upfront (discarded if saved with no title).
- `issues view <number>` - opens an issue by number in the default editor.
- `issues view <number> -c` - prints all comments on an issue to stdout.
- `issues comment <number>` - opens the editor to draft a new comment (saved locally until pushed).
- `issues close <number>` - marks an issue as closed.
- `issues merge <a> <b>` - closes issue `a` as a duplicate of issue `b`, adding cross-reference comments.
- `issues pull` - pulls all issues (and their comments) from GitHub.
- `issues pull <number>` - pulls a single issue and its comments.
- `issues push` - pushes all modified issues and any new local comment drafts to GitHub.
- `issues push <number>` - pushes a single issue and any new local comment drafts.
- `issues sync` - syncs all issues in the current directory using the GitHub CLI.
- `issues help` - shows help for the `issues` command.

### Comments

Each issue has a colocated `.comments.json` file (e.g. `19-add-comment-support.comments.json`) that is created automatically when pulling an issue. It contains all comments fetched from GitHub.

**Typical workflow:**

```sh
issues pull 19 # fetch issue and its comments
issues view 19 -c # read existing comments
issues comment 19 # open editor to write a new comment
issues push 19 # post the draft to GitHub
```

Comments with no `id` in the JSON file are treated as local drafts and posted to GitHub on the next `push`. After pushing, the file is updated with the assigned IDs.

### Agentic

These are planned commnads. The idea is that you will define a preferred agent in a config file.
Expand Down
92 changes: 92 additions & 0 deletions cmd/comment.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
package cmd

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

"github.com/fatih/color"
"github.com/jamesjohnsdev/issues/internal/issue"
"github.com/spf13/cobra"
)

var commentCmd = &cobra.Command{
Use: "comment <number>",
Short: "Draft a new comment on an issue",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
root, err := issuesRoot()
if err != nil {
return err
}

iss, err := findLocalByID(root, args[0])
if err != nil {
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")
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)
}
body := strings.TrimSpace(string(data))
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)
}

fmt.Printf("%s comment draft on #%d — run %s to send\n",
color.GreenString("Saved"),
iss.Number,
color.CyanString("issues push %d", iss.Number),
)
return nil
},
}
161 changes: 161 additions & 0 deletions cmd/comment_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
package cmd

import (
"os"
"path/filepath"
"testing"
"time"

"github.com/jamesjohnsdev/issues/internal/issue"
)

func TestComment(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", "")

err := commentCmd.RunE(commentCmd, []string{"1"})
if err == nil {
t.Error("expected error when no editor is set, got nil")
}
})

t.Run("unknown issue returns error", func(t *testing.T) {
parent := makeProjectDir(t, nil)
chdirTo(t, parent)
t.Setenv("VISUAL", "true")

err := commentCmd.RunE(commentCmd, []string{"99"})
if err == nil {
t.Error("expected error for unknown issue, got nil")
}
})

t.Run("editor that writes empty body discards comment", 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") // no-op editor leaves file empty

_ = captureStdout(t, func() {
if err := commentCmd.RunE(commentCmd, []string{"1"}); err != nil {
t.Fatalf("unexpected error: %v", err)
}
})

path := filepath.Join(parent, issuesDirName, "open", "1-my-issue.comments.json")
if _, err := os.Stat(path); err == nil {
t.Error("expected no comments file after empty-body abort, but file exists")
}
})

t.Run("editor that writes body saves draft to JSON", 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 'hello world' > \"$1\"\n"), 0755); err != nil {
t.Fatal(err)
}
t.Setenv("VISUAL", "")
t.Setenv("EDITOR", script)

_ = captureStdout(t, func() {
if err := commentCmd.RunE(commentCmd, []string{"1"}); err != nil {
t.Fatalf("unexpected error: %v", err)
}
})

commentsFile := filepath.Join(parent, issuesDirName, "open", "1-my-issue.comments.json")
comments, err := issue.ParseComments(commentsFile)
if err != nil {
t.Fatalf("ParseComments: %v", err)
}
if len(comments) != 1 {
t.Fatalf("got %d comments, want 1", len(comments))
}
if comments[0].Metadata != nil {
t.Errorf("expected nil Metadata for draft, got %+v", comments[0].Metadata)
}
if comments[0].Body != "hello world" {
t.Errorf("Body = %q, want %q", comments[0].Body, "hello world")
}
})

t.Run("draft appended after existing comments", func(t *testing.T) {
parent := makeProjectDir(t, []issueFixture{
{"1-my-issue.md", issue.Issue{Number: 1, Title: "My issue", State: "open"}},
})
chdirTo(t, parent)

// Pre-populate comments file with a synced comment
ts := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
existing := []*issue.Comment{
{Metadata: &issue.CommentMeta{ID: "IC_abc", Author: "alice", CreatedAt: &ts}, Body: "existing comment"},
}
commentsFile := filepath.Join(parent, issuesDirName, "open", "1-my-issue.comments.json")
if err := issue.WriteComments(commentsFile, existing); err != nil {
t.Fatalf("WriteComments: %v", err)
}

script := filepath.Join(t.TempDir(), "editor.sh")
if err := os.WriteFile(script, []byte("#!/bin/sh\necho 'new draft' > \"$1\"\n"), 0755); err != nil {
t.Fatal(err)
}
t.Setenv("VISUAL", "")
t.Setenv("EDITOR", script)

_ = captureStdout(t, func() {
if err := commentCmd.RunE(commentCmd, []string{"1"}); err != nil {
t.Fatalf("unexpected error: %v", err)
}
})

comments, err := issue.ParseComments(commentsFile)
if err != nil {
t.Fatalf("ParseComments: %v", err)
}
if len(comments) != 2 {
t.Fatalf("got %d comments, want 2", len(comments))
}
if comments[0].Metadata == nil || comments[0].Metadata.ID != "IC_abc" {
t.Errorf("existing comment should be first with ID %q, got Metadata %+v", "IC_abc", comments[0].Metadata)
}
if comments[1].Body != "new draft" {
t.Errorf("new draft should be last, got body %q", comments[1].Body)
}
})

t.Run("whitespace-only body discards comment", 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\nprintf ' \\n' > \"$1\"\n"), 0755); err != nil {
t.Fatal(err)
}
t.Setenv("VISUAL", "")
t.Setenv("EDITOR", script)

_ = captureStdout(t, func() {
if err := commentCmd.RunE(commentCmd, []string{"1"}); err != nil {
t.Fatalf("unexpected error: %v", err)
}
})

path := filepath.Join(parent, issuesDirName, "open", "1-my-issue.comments.json")
if _, err := os.Stat(path); err == nil {
t.Error("expected no comments file after whitespace-only body, but file exists")
}
})
}
Loading