From 8b4fb46cff61c2b7c930dbdf540fa034e9786267 Mon Sep 17 00:00:00 2001 From: James Johns <192176108+jamesjohnsdev@users.noreply.github.com> Date: Sat, 20 Jun 2026 18:48:48 +1000 Subject: [PATCH 1/8] feat: comment compatability --- cmd/pull.go | 37 +++++++++++++++++++++++++++++++-- cmd/push.go | 35 ++++++++++++++++++++++++++++++- internal/gh/client.go | 43 +++++++++++++++++++++++++++++++++++++++ internal/issue/comment.go | 42 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 154 insertions(+), 3 deletions(-) create mode 100644 internal/issue/comment.go diff --git a/cmd/pull.go b/cmd/pull.go index f3afd6c..bc18556 100644 --- a/cmd/pull.go +++ b/cmd/pull.go @@ -55,13 +55,20 @@ func pullOne(root string, iss *issue.Issue) error { dir := stateDir(root, iss.State) destPath := filepath.Join(dir, issue.Filename(iss)) + commentsPath := filepath.Join(dir, issue.CommentsFilename(iss)) - // If the issue exists locally in a different location (e.g. state changed), remove the old file + // If the issue exists locally in a different location (e.g. state changed), remove the old files existing, _ := findLocalByNumber(root, iss.Number) if existing != nil && existing.Path != destPath { if err := os.Remove(existing.Path); err != nil { return fmt.Errorf("removing old local issue: %w", err) } + oldCommentsPath := filepath.Join(filepath.Dir(existing.Path), issue.CommentsFilename(existing)) + if _, err := os.Stat(oldCommentsPath); err == nil { + if err := os.Rename(oldCommentsPath, commentsPath); err != nil { + return fmt.Errorf("moving comments file: %w", err) + } + } } if err := issue.Write(destPath, iss); err != nil { @@ -69,7 +76,33 @@ func pullOne(root string, iss *issue.Issue) error { } origPath := filepath.Join(originalsDir(root), fmt.Sprintf("%d.md", iss.Number)) - return saveOriginal(destPath, origPath) + if err := saveOriginal(destPath, origPath); err != nil { + return err + } + + return pullComments(iss, commentsPath) +} + +func pullComments(iss *issue.Issue, commentsPath string) error { + remote, err := gh.GetComments(iss.Number) + if err != nil { + return err + } + + // Preserve any local-only comments (those without an id) by merging them in + local, _ := issue.ParseComments(commentsPath) + remoteIDs := make(map[string]bool, len(remote)) + for _, c := range remote { + remoteIDs[c.ID] = true + } + for _, c := range local { + if c.ID == "" { + remote = append(remote, c) + } + } + _ = remoteIDs + + return issue.WriteComments(commentsPath, remote) } func saveOriginal(src, dst string) error { diff --git a/cmd/push.go b/cmd/push.go index e0fc42c..5a8de3c 100644 --- a/cmd/push.go +++ b/cmd/push.go @@ -104,5 +104,38 @@ func pushOne(root string, iss *issue.Issue) error { } fmt.Printf("%s #%d: %s\n", color.GreenString("Pushed"), iss.Number, iss.Title) - return nil + + return pushNewComments(iss) +} + +// pushNewComments posts any local-only comments (those with no id) to GitHub, +// then re-fetches comments to update the local file with their assigned IDs. +func pushNewComments(iss *issue.Issue) error { + commentsPath := filepath.Join(filepath.Dir(iss.Path), issue.CommentsFilename(iss)) + local, err := issue.ParseComments(commentsPath) + if err != nil || local == nil { + return err + } + + pushed := 0 + for _, c := range local { + if c.ID == "" { + if err := gh.AddComment(iss.Number, c.Body); err != nil { + return err + } + pushed++ + } + } + + if pushed == 0 { + return nil + } + + // Re-fetch so the file reflects the newly assigned IDs + fmt.Printf("%s %d comment(s) on #%d\n", color.GreenString("Pushed"), pushed, iss.Number) + remote, err := gh.GetComments(iss.Number) + if err != nil { + return err + } + return issue.WriteComments(commentsPath, remote) } diff --git a/internal/gh/client.go b/internal/gh/client.go index 246c676..efea941 100644 --- a/internal/gh/client.go +++ b/internal/gh/client.go @@ -11,6 +11,7 @@ import ( ) const jsonFields = "number,title,state,stateReason,body,labels,assignees,milestone" +const commentFields = "comments" type ghLabel struct { Name string `json:"name"` @@ -170,6 +171,48 @@ func Update(iss *issue.Issue) error { return nil } +type ghComment struct { + ID string `json:"id"` + Author ghUser `json:"author"` + Body string `json:"body"` + CreatedAt time.Time `json:"createdAt"` +} + +// GetComments fetches all comments for a GitHub issue. +func GetComments(number int) ([]*issue.Comment, error) { + out, err := run("gh", "issue", "view", fmt.Sprintf("%d", number), + "--json", commentFields, + ) + if err != nil { + return nil, fmt.Errorf("gh issue view %d: %w", number, err) + } + var raw struct { + Comments []ghComment `json:"comments"` + } + if err := json.Unmarshal(out, &raw); err != nil { + return nil, err + } + comments := make([]*issue.Comment, len(raw.Comments)) + for i, c := range raw.Comments { + t := c.CreatedAt + comments[i] = &issue.Comment{ + ID: c.ID, + Author: c.Author.Login, + CreatedAt: &t, + Body: c.Body, + } + } + return comments, nil +} + +// AddComment posts a new comment on a GitHub issue. +func AddComment(number int, body string) error { + if _, err := run("gh", "issue", "comment", fmt.Sprintf("%d", number), "--body", body); err != nil { + return fmt.Errorf("gh issue comment %d: %w", number, err) + } + return nil +} + // Delete permanently deletes a GitHub issue. func Delete(number int) error { if _, err := run("gh", "issue", "delete", fmt.Sprintf("%d", number), "--yes"); err != nil { diff --git a/internal/issue/comment.go b/internal/issue/comment.go new file mode 100644 index 0000000..17966ad --- /dev/null +++ b/internal/issue/comment.go @@ -0,0 +1,42 @@ +package issue + +import ( + "encoding/json" + "fmt" + "os" + "time" +) + +type Comment struct { + ID string `json:"id,omitempty"` + Author string `json:"author,omitempty"` + CreatedAt *time.Time `json:"created_at,omitempty"` + Body string `json:"body"` +} + +func CommentsFilename(iss *Issue) string { + return fmt.Sprintf("%d-%s.comments.json", iss.Number, Slug(iss.Title)) +} + +func ParseComments(path string) ([]*Comment, error) { + data, err := os.ReadFile(path) + if os.IsNotExist(err) { + return nil, nil + } + if err != nil { + return nil, err + } + var comments []*Comment + if err := json.Unmarshal(data, &comments); err != nil { + return nil, fmt.Errorf("%s: %w", path, err) + } + return comments, nil +} + +func WriteComments(path string, comments []*Comment) error { + data, err := json.MarshalIndent(comments, "", " ") + if err != nil { + return err + } + return os.WriteFile(path, append(data, '\n'), 0644) +} From af1e483fc7cc492b807868a92089834d92f9fa00 Mon Sep 17 00:00:00 2001 From: James Johns <192176108+jamesjohnsdev@users.noreply.github.com> Date: Sat, 20 Jun 2026 18:53:10 +1000 Subject: [PATCH 2/8] doc: comments feature added to readme doc: basic updates of missing gaps --- README.md | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/README.md b/README.md index d955fbc..c4c4aa2 100644 --- a/README.md +++ b/README.md @@ -17,9 +17,35 @@ also for AI augmented workflows, where agents can have a source for development. - `issues create -e` - opens a blank issue in the editor without requiring a title upfront (discarded if saved with no title). - `issues view ` - opens an issue by number in the default editor. - `issues close ` - marks an issue as closed. +- `issues pull` - pulls all issues (and their comments) from GitHub. +- `issues pull ` - pulls a single issue and its comments. +- `issues push` - pushes all modified issues and any new local comments to GitHub. +- `issues push ` - pushes a single issue and any new local comments. - `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. + +To add a new comment locally, append an entry with only a `body` field: + +```json +[ + { + "id": "IC_kwDO...", + "author": "jamesjohnsdev", + "created_at": "2026-06-20T10:00:00Z", + "body": "Existing comment from GitHub." + }, + { + "body": "My new comment — will be posted on the next push." + } +] +``` + +Running `issues push ` will post any entries without an `id` to GitHub and update the file with their assigned IDs. + ### Agentic These are planned commnads. The idea is that you will define a preferred agent in a config file. From bfb4f40c2bc5e0894de65129120f7d5a881465ca Mon Sep 17 00:00:00 2001 From: James Johns <192176108+jamesjohnsdev@users.noreply.github.com> Date: Sat, 20 Jun 2026 18:57:56 +1000 Subject: [PATCH 3/8] test: comments --- cmd/util_test.go | 17 +++ internal/issue/comment_test.go | 234 +++++++++++++++++++++++++++++++++ 2 files changed, 251 insertions(+) create mode 100644 internal/issue/comment_test.go diff --git a/cmd/util_test.go b/cmd/util_test.go index 6fa4245..76589e0 100644 --- a/cmd/util_test.go +++ b/cmd/util_test.go @@ -240,6 +240,23 @@ func TestLoadAllLocal(t *testing.T) { } }) + t.Run("skips colocated comments JSON files", func(t *testing.T) { + root := makeIssuesRoot(t, []issueFixture{ + {"1-issue.md", issue.Issue{Number: 1, Title: "Issue", State: "open"}}, + }) + commentsJSON := filepath.Join(root, "open", "1-issue.comments.json") + if err := os.WriteFile(commentsJSON, []byte(`[{"body":"a comment"}]`), 0644); err != nil { + t.Fatal(err) + } + issues, err := loadAllLocal(root) + if err != nil { + t.Fatal(err) + } + if len(issues) != 1 { + t.Errorf("got %d issues, want 1 (comments JSON should be ignored)", len(issues)) + } + }) + t.Run("skips directories inside open", func(t *testing.T) { root := makeIssuesRoot(t, nil) if err := os.MkdirAll(filepath.Join(root, "open", "subdir.md"), 0755); err != nil { diff --git a/internal/issue/comment_test.go b/internal/issue/comment_test.go new file mode 100644 index 0000000..43e9d5e --- /dev/null +++ b/internal/issue/comment_test.go @@ -0,0 +1,234 @@ +package issue + +import ( + "os" + "path/filepath" + "testing" + "time" +) + +func TestCommentsFilename(t *testing.T) { + tests := []struct { + name string + iss Issue + want string + }{ + { + name: "basic issue", + iss: Issue{Number: 1, Title: "Hello World"}, + want: "1-hello-world.comments.json", + }, + { + name: "special chars in title", + iss: Issue{Number: 42, Title: "Fix: bug #42!"}, + want: "42-fix-bug-42.comments.json", + }, + { + name: "long title is truncated", + iss: Issue{Number: 3, Title: "this is a very long title that goes way beyond fifty characters"}, + want: "3-this-is-a-very-long-title-that-goes-way-beyond.comments.json", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := CommentsFilename(&tt.iss) + if got != tt.want { + t.Errorf("CommentsFilename() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestParseComments(t *testing.T) { + t.Run("missing file returns nil slice", func(t *testing.T) { + comments, err := ParseComments("/no/such/file.comments.json") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if comments != nil { + t.Errorf("expected nil, got %v", comments) + } + }) + + t.Run("empty array", func(t *testing.T) { + path := filepath.Join(t.TempDir(), "test.comments.json") + if err := os.WriteFile(path, []byte("[]\n"), 0644); err != nil { + t.Fatal(err) + } + comments, err := ParseComments(path) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(comments) != 0 { + t.Errorf("got %d comments, want 0", len(comments)) + } + }) + + t.Run("synced comment with all fields", func(t *testing.T) { + path := filepath.Join(t.TempDir(), "test.comments.json") + raw := `[{"id":"IC_abc123","author":"alice","created_at":"2026-01-01T00:00:00Z","body":"hello"}]` + if err := os.WriteFile(path, []byte(raw), 0644); err != nil { + t.Fatal(err) + } + comments, err := ParseComments(path) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(comments) != 1 { + t.Fatalf("got %d comments, want 1", len(comments)) + } + c := comments[0] + if c.ID != "IC_abc123" { + t.Errorf("ID = %q, want %q", c.ID, "IC_abc123") + } + if c.Author != "alice" { + t.Errorf("Author = %q, want %q", c.Author, "alice") + } + if c.Body != "hello" { + t.Errorf("Body = %q, want %q", c.Body, "hello") + } + }) + + t.Run("local-only comment with body only", func(t *testing.T) { + path := filepath.Join(t.TempDir(), "test.comments.json") + raw := `[{"body":"draft comment"}]` + if err := os.WriteFile(path, []byte(raw), 0644); err != nil { + t.Fatal(err) + } + comments, err := ParseComments(path) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(comments) != 1 { + t.Fatalf("got %d comments, want 1", len(comments)) + } + if comments[0].ID != "" { + t.Errorf("expected empty ID for local comment, got %q", comments[0].ID) + } + if comments[0].Body != "draft comment" { + t.Errorf("Body = %q, want %q", comments[0].Body, "draft comment") + } + }) + + t.Run("invalid JSON returns error", func(t *testing.T) { + path := filepath.Join(t.TempDir(), "test.comments.json") + if err := os.WriteFile(path, []byte("not json"), 0644); err != nil { + t.Fatal(err) + } + _, err := ParseComments(path) + if err == nil { + t.Error("expected error for invalid JSON, got nil") + } + }) +} + +func TestWriteParseComments(t *testing.T) { + t.Run("empty slice round-trips", func(t *testing.T) { + path := filepath.Join(t.TempDir(), "test.comments.json") + if err := WriteComments(path, []*Comment{}); err != nil { + t.Fatalf("WriteComments: %v", err) + } + got, err := ParseComments(path) + if err != nil { + t.Fatalf("ParseComments: %v", err) + } + if len(got) != 0 { + t.Errorf("got %d comments, want 0", len(got)) + } + }) + + t.Run("synced comment round-trips", func(t *testing.T) { + ts := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + in := []*Comment{ + {ID: "IC_abc", Author: "alice", CreatedAt: &ts, Body: "hello"}, + } + path := filepath.Join(t.TempDir(), "test.comments.json") + if err := WriteComments(path, in); err != nil { + t.Fatalf("WriteComments: %v", err) + } + got, err := ParseComments(path) + if err != nil { + t.Fatalf("ParseComments: %v", err) + } + if len(got) != 1 { + t.Fatalf("got %d comments, want 1", len(got)) + } + c := got[0] + if c.ID != "IC_abc" { + t.Errorf("ID = %q, want %q", c.ID, "IC_abc") + } + if c.Author != "alice" { + t.Errorf("Author = %q, want %q", c.Author, "alice") + } + if c.Body != "hello" { + t.Errorf("Body = %q, want %q", c.Body, "hello") + } + if !c.CreatedAt.Equal(ts) { + t.Errorf("CreatedAt = %v, want %v", c.CreatedAt, ts) + } + }) + + t.Run("local-only comment round-trips with empty id", func(t *testing.T) { + in := []*Comment{{Body: "new comment"}} + path := filepath.Join(t.TempDir(), "test.comments.json") + if err := WriteComments(path, in); err != nil { + t.Fatalf("WriteComments: %v", err) + } + got, err := ParseComments(path) + if err != nil { + t.Fatalf("ParseComments: %v", err) + } + if len(got) != 1 { + t.Fatalf("got %d comments, want 1", len(got)) + } + if got[0].ID != "" { + t.Errorf("expected empty ID, got %q", got[0].ID) + } + if got[0].Body != "new comment" { + t.Errorf("Body = %q, want %q", got[0].Body, "new comment") + } + }) + + t.Run("mixed synced and local comments round-trip", func(t *testing.T) { + ts := time.Date(2026, 6, 1, 12, 0, 0, 0, time.UTC) + in := []*Comment{ + {ID: "IC_xyz", Author: "bob", CreatedAt: &ts, Body: "existing"}, + {Body: "my draft"}, + } + path := filepath.Join(t.TempDir(), "test.comments.json") + if err := WriteComments(path, in); err != nil { + t.Fatalf("WriteComments: %v", err) + } + got, err := ParseComments(path) + if err != nil { + t.Fatalf("ParseComments: %v", err) + } + if len(got) != 2 { + t.Fatalf("got %d comments, want 2", len(got)) + } + if got[0].ID != "IC_xyz" { + t.Errorf("got[0].ID = %q, want %q", got[0].ID, "IC_xyz") + } + if got[1].ID != "" { + t.Errorf("got[1].ID = %q, want empty", got[1].ID) + } + if got[1].Body != "my draft" { + t.Errorf("got[1].Body = %q, want %q", got[1].Body, "my draft") + } + }) + + t.Run("output file ends with newline", func(t *testing.T) { + path := filepath.Join(t.TempDir(), "test.comments.json") + if err := WriteComments(path, []*Comment{{Body: "x"}}); err != nil { + t.Fatalf("WriteComments: %v", err) + } + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if len(data) == 0 || data[len(data)-1] != '\n' { + t.Errorf("file does not end with newline: %q", string(data)) + } + }) +} From 594d5725631c62d4c909f935f851fe3650af0c56 Mon Sep 17 00:00:00 2001 From: James Johns <192176108+jamesjohnsdev@users.noreply.github.com> Date: Sat, 20 Jun 2026 19:13:50 +1000 Subject: [PATCH 4/8] feat: cli commands for issue comments --- README.md | 29 ++++---- cmd/comment.go | 82 ++++++++++++++++++++++ cmd/comment_test.go | 161 +++++++++++++++++++++++++++++++++++++++++++ cmd/root.go | 1 + cmd/view.go | 48 +++++++++++++ cmd/view_test.go | 164 ++++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 468 insertions(+), 17 deletions(-) create mode 100644 cmd/comment.go create mode 100644 cmd/comment_test.go create mode 100644 cmd/view_test.go diff --git a/README.md b/README.md index c4c4aa2..9aebfa8 100644 --- a/README.md +++ b/README.md @@ -16,11 +16,13 @@ also for AI augmented workflows, where agents can have a source for development. - `issues create ` - 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 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 comments to GitHub. -- `issues push <number>` - pushes a single issue and any new local 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. @@ -28,23 +30,16 @@ also for AI augmented workflows, where agents can have a source for development. 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. -To add a new comment locally, append an entry with only a `body` field: - -```json -[ - { - "id": "IC_kwDO...", - "author": "jamesjohnsdev", - "created_at": "2026-06-20T10:00:00Z", - "body": "Existing comment from GitHub." - }, - { - "body": "My new comment — will be posted on the next push." - } -] +**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 ``` -Running `issues push <number>` will post any entries without an `id` to GitHub and update the file with their assigned IDs. +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 diff --git a/cmd/comment.go b/cmd/comment.go new file mode 100644 index 0000000..279b34a --- /dev/null +++ b/cmd/comment.go @@ -0,0 +1,82 @@ +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() + defer os.Remove(tmpPath) + tmp.Close() + + 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 + }, +} diff --git a/cmd/comment_test.go b/cmd/comment_test.go new file mode 100644 index 0000000..9254d11 --- /dev/null +++ b/cmd/comment_test.go @@ -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].ID != "" { + t.Errorf("expected empty ID for draft, got %q", comments[0].ID) + } + 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{ + {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].ID != "IC_abc" { + t.Errorf("existing comment should be first, got ID %q", comments[0].ID) + } + 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") + } + }) +} diff --git a/cmd/root.go b/cmd/root.go index 737922f..798a0d5 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -67,6 +67,7 @@ Use "{{.CommandPath}} [command] --help" for more information about a command.{{e rootCmd.AddCommand(pushCmd) rootCmd.AddCommand(pullCmd) rootCmd.AddCommand(viewCmd) + rootCmd.AddCommand(commentCmd) rootCmd.AddCommand(closeCmd) rootCmd.AddCommand(deleteCmd) } diff --git a/cmd/view.go b/cmd/view.go index 5a02a2c..733275c 100644 --- a/cmd/view.go +++ b/cmd/view.go @@ -4,11 +4,17 @@ import ( "fmt" "os" "os/exec" + "path/filepath" "strings" + "time" + "github.com/fatih/color" + "github.com/jamesjohnsdev/issues/internal/issue" "github.com/spf13/cobra" ) +var viewCommentsFlag bool + var viewCmd = &cobra.Command{ Use: "view <number>", Short: "Open an issue in the default editor", @@ -24,6 +30,10 @@ var viewCmd = &cobra.Command{ return err } + if viewCommentsFlag { + return printComments(iss) + } + editor := os.Getenv("VISUAL") if editor == "" { editor = os.Getenv("EDITOR") @@ -40,3 +50,41 @@ var viewCmd = &cobra.Command{ return c.Run() }, } + +func printComments(iss *issue.Issue) error { + 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) + } + + if len(comments) == 0 { + fmt.Printf("%s no comments on #%d\n", color.New(color.FgHiBlack).Sprint("—"), iss.Number) + return nil + } + + bold := color.New(color.Bold).SprintFunc() + dim := color.New(color.FgHiBlack).SprintFunc() + draft := color.New(color.FgYellow).Sprint("[draft]") + + for i, c := range comments { + if i > 0 { + fmt.Println() + } + if c.ID == "" { + fmt.Printf("%s %s\n", bold("draft"), draft) + } else { + ts := "" + if c.CreatedAt != nil { + ts = " · " + c.CreatedAt.In(time.Local).Format("2 Jan 2006 15:04") + } + fmt.Printf("%s%s\n", bold(c.Author), dim(ts)) + } + fmt.Println(c.Body) + } + return nil +} + +func init() { + viewCmd.Flags().BoolVarP(&viewCommentsFlag, "comments", "c", false, "show comments instead of opening the editor") +} diff --git a/cmd/view_test.go b/cmd/view_test.go new file mode 100644 index 0000000..01efeb9 --- /dev/null +++ b/cmd/view_test.go @@ -0,0 +1,164 @@ +package cmd + +import ( + "path/filepath" + "strings" + "testing" + "time" + + "github.com/jamesjohnsdev/issues/internal/issue" +) + +func resetViewFlags(t *testing.T) { + t.Helper() + orig := viewCommentsFlag + t.Cleanup(func() { viewCommentsFlag = orig }) + viewCommentsFlag = false +} + +func TestViewComments(t *testing.T) { + t.Run("unknown issue returns error", func(t *testing.T) { + parent := makeProjectDir(t, nil) + chdirTo(t, parent) + resetViewFlags(t) + viewCommentsFlag = true + + err := viewCmd.RunE(viewCmd, []string{"99"}) + if err == nil { + t.Error("expected error for unknown issue, got nil") + } + }) + + t.Run("no comments file prints empty message", 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) + viewCommentsFlag = true + + out := captureStdout(t, func() { + if err := viewCmd.RunE(viewCmd, []string{"1"}); err != nil { + t.Fatalf("unexpected error: %v", err) + } + }) + + if !strings.Contains(out, "no comments") { + t.Errorf("expected 'no comments' in output, got: %q", out) + } + }) + + t.Run("synced comment shows author and body", func(t *testing.T) { + parent := makeProjectDir(t, []issueFixture{ + {"1-my-issue.md", issue.Issue{Number: 1, Title: "My issue", State: "open"}}, + }) + chdirTo(t, parent) + + ts := time.Date(2026, 1, 15, 10, 30, 0, 0, time.UTC) + comments := []*issue.Comment{ + {ID: "IC_abc", Author: "alice", CreatedAt: &ts, Body: "looks good"}, + } + commentsFile := filepath.Join(parent, issuesDirName, "open", "1-my-issue.comments.json") + if err := issue.WriteComments(commentsFile, comments); err != nil { + t.Fatal(err) + } + + resetViewFlags(t) + viewCommentsFlag = true + + out := captureStdout(t, func() { + if err := viewCmd.RunE(viewCmd, []string{"1"}); err != nil { + t.Fatalf("unexpected error: %v", err) + } + }) + + if !strings.Contains(out, "alice") { + t.Errorf("expected author 'alice' in output, got: %q", out) + } + if !strings.Contains(out, "looks good") { + t.Errorf("expected body 'looks good' in output, got: %q", out) + } + }) + + t.Run("local draft shows draft label", func(t *testing.T) { + parent := makeProjectDir(t, []issueFixture{ + {"1-my-issue.md", issue.Issue{Number: 1, Title: "My issue", State: "open"}}, + }) + chdirTo(t, parent) + + comments := []*issue.Comment{ + {Body: "my pending comment"}, + } + commentsFile := filepath.Join(parent, issuesDirName, "open", "1-my-issue.comments.json") + if err := issue.WriteComments(commentsFile, comments); err != nil { + t.Fatal(err) + } + + resetViewFlags(t) + viewCommentsFlag = true + + out := captureStdout(t, func() { + if err := viewCmd.RunE(viewCmd, []string{"1"}); err != nil { + t.Fatalf("unexpected error: %v", err) + } + }) + + if !strings.Contains(out, "draft") { + t.Errorf("expected 'draft' label in output for local comment, got: %q", out) + } + if !strings.Contains(out, "my pending comment") { + t.Errorf("expected body in output, got: %q", out) + } + }) + + t.Run("mixed comments preserves order", func(t *testing.T) { + parent := makeProjectDir(t, []issueFixture{ + {"1-my-issue.md", issue.Issue{Number: 1, Title: "My issue", State: "open"}}, + }) + chdirTo(t, parent) + + ts := time.Date(2026, 3, 1, 0, 0, 0, 0, time.UTC) + comments := []*issue.Comment{ + {ID: "IC_1", Author: "bob", CreatedAt: &ts, Body: "first"}, + {Body: "second draft"}, + } + commentsFile := filepath.Join(parent, issuesDirName, "open", "1-my-issue.comments.json") + if err := issue.WriteComments(commentsFile, comments); err != nil { + t.Fatal(err) + } + + resetViewFlags(t) + viewCommentsFlag = true + + out := captureStdout(t, func() { + if err := viewCmd.RunE(viewCmd, []string{"1"}); err != nil { + t.Fatalf("unexpected error: %v", err) + } + }) + + firstIdx := strings.Index(out, "first") + secondIdx := strings.Index(out, "second draft") + if firstIdx == -1 || secondIdx == -1 { + t.Fatalf("expected both comment bodies in output, got: %q", out) + } + if firstIdx > secondIdx { + t.Errorf("expected 'first' before 'second draft' in output") + } + }) + + t.Run("without flag opens editor", 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) // viewCommentsFlag = false + + // Use a no-op editor so the test doesn't hang + t.Setenv("VISUAL", "") + t.Setenv("EDITOR", "true") + + if err := viewCmd.RunE(viewCmd, []string{"1"}); err != nil { + t.Fatalf("unexpected error: %v", err) + } + }) +} From 308df2edc946d5cf5096c0d825be883e506cd9ff Mon Sep 17 00:00:00 2001 From: James Johns <192176108+jamesjohnsdev@users.noreply.github.com> Date: Sat, 20 Jun 2026 23:39:28 +1000 Subject: [PATCH 5/8] feat: enable metadata support for comments --- cmd/comment_test.go | 10 +++---- cmd/pull.go | 6 ++-- cmd/push.go | 2 +- cmd/view.go | 8 +++--- cmd/view_test.go | 4 +-- internal/gh/client.go | 10 ++++--- internal/issue/comment.go | 8 ++++-- internal/issue/comment_test.go | 50 +++++++++++++++++++--------------- 8 files changed, 56 insertions(+), 42 deletions(-) diff --git a/cmd/comment_test.go b/cmd/comment_test.go index 9254d11..3b6257f 100644 --- a/cmd/comment_test.go +++ b/cmd/comment_test.go @@ -82,8 +82,8 @@ func TestComment(t *testing.T) { if len(comments) != 1 { t.Fatalf("got %d comments, want 1", len(comments)) } - if comments[0].ID != "" { - t.Errorf("expected empty ID for draft, got %q", comments[0].ID) + 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") @@ -99,7 +99,7 @@ func TestComment(t *testing.T) { // Pre-populate comments file with a synced comment ts := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) existing := []*issue.Comment{ - {ID: "IC_abc", Author: "alice", CreatedAt: &ts, Body: "existing 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 { @@ -126,8 +126,8 @@ func TestComment(t *testing.T) { if len(comments) != 2 { t.Fatalf("got %d comments, want 2", len(comments)) } - if comments[0].ID != "IC_abc" { - t.Errorf("existing comment should be first, got ID %q", comments[0].ID) + 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) diff --git a/cmd/pull.go b/cmd/pull.go index bc18556..d8603af 100644 --- a/cmd/pull.go +++ b/cmd/pull.go @@ -93,10 +93,12 @@ func pullComments(iss *issue.Issue, commentsPath string) error { local, _ := issue.ParseComments(commentsPath) remoteIDs := make(map[string]bool, len(remote)) for _, c := range remote { - remoteIDs[c.ID] = true + if c.Metadata != nil { + remoteIDs[c.Metadata.ID] = true + } } for _, c := range local { - if c.ID == "" { + if c.Metadata == nil { remote = append(remote, c) } } diff --git a/cmd/push.go b/cmd/push.go index 5a8de3c..3ce7075 100644 --- a/cmd/push.go +++ b/cmd/push.go @@ -119,7 +119,7 @@ func pushNewComments(iss *issue.Issue) error { pushed := 0 for _, c := range local { - if c.ID == "" { + if c.Metadata == nil { if err := gh.AddComment(iss.Number, c.Body); err != nil { return err } diff --git a/cmd/view.go b/cmd/view.go index 733275c..08bda43 100644 --- a/cmd/view.go +++ b/cmd/view.go @@ -71,14 +71,14 @@ func printComments(iss *issue.Issue) error { if i > 0 { fmt.Println() } - if c.ID == "" { + if c.Metadata == nil { fmt.Printf("%s %s\n", bold("draft"), draft) } else { ts := "" - if c.CreatedAt != nil { - ts = " · " + c.CreatedAt.In(time.Local).Format("2 Jan 2006 15:04") + if c.Metadata.CreatedAt != nil { + ts = " · " + c.Metadata.CreatedAt.In(time.Local).Format("2 Jan 2006 15:04") } - fmt.Printf("%s%s\n", bold(c.Author), dim(ts)) + fmt.Printf("%s%s\n", bold(c.Metadata.Author), dim(ts)) } fmt.Println(c.Body) } diff --git a/cmd/view_test.go b/cmd/view_test.go index 01efeb9..63d40a6 100644 --- a/cmd/view_test.go +++ b/cmd/view_test.go @@ -56,7 +56,7 @@ func TestViewComments(t *testing.T) { ts := time.Date(2026, 1, 15, 10, 30, 0, 0, time.UTC) comments := []*issue.Comment{ - {ID: "IC_abc", Author: "alice", CreatedAt: &ts, Body: "looks good"}, + {Metadata: &issue.CommentMeta{ID: "IC_abc", Author: "alice", CreatedAt: &ts}, Body: "looks good"}, } commentsFile := filepath.Join(parent, issuesDirName, "open", "1-my-issue.comments.json") if err := issue.WriteComments(commentsFile, comments); err != nil { @@ -119,7 +119,7 @@ func TestViewComments(t *testing.T) { ts := time.Date(2026, 3, 1, 0, 0, 0, 0, time.UTC) comments := []*issue.Comment{ - {ID: "IC_1", Author: "bob", CreatedAt: &ts, Body: "first"}, + {Metadata: &issue.CommentMeta{ID: "IC_1", Author: "bob", CreatedAt: &ts}, Body: "first"}, {Body: "second draft"}, } commentsFile := filepath.Join(parent, issuesDirName, "open", "1-my-issue.comments.json") diff --git a/internal/gh/client.go b/internal/gh/client.go index efea941..c33d3ef 100644 --- a/internal/gh/client.go +++ b/internal/gh/client.go @@ -196,10 +196,12 @@ func GetComments(number int) ([]*issue.Comment, error) { for i, c := range raw.Comments { t := c.CreatedAt comments[i] = &issue.Comment{ - ID: c.ID, - Author: c.Author.Login, - CreatedAt: &t, - Body: c.Body, + Metadata: &issue.CommentMeta{ + ID: c.ID, + Author: c.Author.Login, + CreatedAt: &t, + }, + Body: c.Body, } } return comments, nil diff --git a/internal/issue/comment.go b/internal/issue/comment.go index 17966ad..182a8b6 100644 --- a/internal/issue/comment.go +++ b/internal/issue/comment.go @@ -7,11 +7,15 @@ import ( "time" ) -type Comment struct { +type CommentMeta struct { ID string `json:"id,omitempty"` Author string `json:"author,omitempty"` CreatedAt *time.Time `json:"created_at,omitempty"` - Body string `json:"body"` +} + +type Comment struct { + Metadata *CommentMeta `json:"metadata,omitempty"` + Body string `json:"body"` } func CommentsFilename(iss *Issue) string { diff --git a/internal/issue/comment_test.go b/internal/issue/comment_test.go index 43e9d5e..db85c09 100644 --- a/internal/issue/comment_test.go +++ b/internal/issue/comment_test.go @@ -67,7 +67,7 @@ func TestParseComments(t *testing.T) { t.Run("synced comment with all fields", func(t *testing.T) { path := filepath.Join(t.TempDir(), "test.comments.json") - raw := `[{"id":"IC_abc123","author":"alice","created_at":"2026-01-01T00:00:00Z","body":"hello"}]` + raw := `[{"metadata":{"id":"IC_abc123","author":"alice","created_at":"2026-01-01T00:00:00Z"},"body":"hello"}]` if err := os.WriteFile(path, []byte(raw), 0644); err != nil { t.Fatal(err) } @@ -79,11 +79,14 @@ func TestParseComments(t *testing.T) { t.Fatalf("got %d comments, want 1", len(comments)) } c := comments[0] - if c.ID != "IC_abc123" { - t.Errorf("ID = %q, want %q", c.ID, "IC_abc123") + if c.Metadata == nil { + t.Fatal("Metadata is nil, want non-nil") } - if c.Author != "alice" { - t.Errorf("Author = %q, want %q", c.Author, "alice") + if c.Metadata.ID != "IC_abc123" { + t.Errorf("ID = %q, want %q", c.Metadata.ID, "IC_abc123") + } + if c.Metadata.Author != "alice" { + t.Errorf("Author = %q, want %q", c.Metadata.Author, "alice") } if c.Body != "hello" { t.Errorf("Body = %q, want %q", c.Body, "hello") @@ -103,8 +106,8 @@ func TestParseComments(t *testing.T) { if len(comments) != 1 { t.Fatalf("got %d comments, want 1", len(comments)) } - if comments[0].ID != "" { - t.Errorf("expected empty ID for local comment, got %q", comments[0].ID) + if comments[0].Metadata != nil { + t.Errorf("expected nil Metadata for local comment, got %+v", comments[0].Metadata) } if comments[0].Body != "draft comment" { t.Errorf("Body = %q, want %q", comments[0].Body, "draft comment") @@ -141,7 +144,7 @@ func TestWriteParseComments(t *testing.T) { t.Run("synced comment round-trips", func(t *testing.T) { ts := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) in := []*Comment{ - {ID: "IC_abc", Author: "alice", CreatedAt: &ts, Body: "hello"}, + {Metadata: &CommentMeta{ID: "IC_abc", Author: "alice", CreatedAt: &ts}, Body: "hello"}, } path := filepath.Join(t.TempDir(), "test.comments.json") if err := WriteComments(path, in); err != nil { @@ -155,21 +158,24 @@ func TestWriteParseComments(t *testing.T) { t.Fatalf("got %d comments, want 1", len(got)) } c := got[0] - if c.ID != "IC_abc" { - t.Errorf("ID = %q, want %q", c.ID, "IC_abc") + if c.Metadata == nil { + t.Fatal("Metadata is nil, want non-nil") + } + if c.Metadata.ID != "IC_abc" { + t.Errorf("ID = %q, want %q", c.Metadata.ID, "IC_abc") } - if c.Author != "alice" { - t.Errorf("Author = %q, want %q", c.Author, "alice") + if c.Metadata.Author != "alice" { + t.Errorf("Author = %q, want %q", c.Metadata.Author, "alice") } if c.Body != "hello" { t.Errorf("Body = %q, want %q", c.Body, "hello") } - if !c.CreatedAt.Equal(ts) { - t.Errorf("CreatedAt = %v, want %v", c.CreatedAt, ts) + if !c.Metadata.CreatedAt.Equal(ts) { + t.Errorf("CreatedAt = %v, want %v", c.Metadata.CreatedAt, ts) } }) - t.Run("local-only comment round-trips with empty id", func(t *testing.T) { + t.Run("local-only comment round-trips with nil metadata", func(t *testing.T) { in := []*Comment{{Body: "new comment"}} path := filepath.Join(t.TempDir(), "test.comments.json") if err := WriteComments(path, in); err != nil { @@ -182,8 +188,8 @@ func TestWriteParseComments(t *testing.T) { if len(got) != 1 { t.Fatalf("got %d comments, want 1", len(got)) } - if got[0].ID != "" { - t.Errorf("expected empty ID, got %q", got[0].ID) + if got[0].Metadata != nil { + t.Errorf("expected nil Metadata, got %+v", got[0].Metadata) } if got[0].Body != "new comment" { t.Errorf("Body = %q, want %q", got[0].Body, "new comment") @@ -193,7 +199,7 @@ func TestWriteParseComments(t *testing.T) { t.Run("mixed synced and local comments round-trip", func(t *testing.T) { ts := time.Date(2026, 6, 1, 12, 0, 0, 0, time.UTC) in := []*Comment{ - {ID: "IC_xyz", Author: "bob", CreatedAt: &ts, Body: "existing"}, + {Metadata: &CommentMeta{ID: "IC_xyz", Author: "bob", CreatedAt: &ts}, Body: "existing"}, {Body: "my draft"}, } path := filepath.Join(t.TempDir(), "test.comments.json") @@ -207,11 +213,11 @@ func TestWriteParseComments(t *testing.T) { if len(got) != 2 { t.Fatalf("got %d comments, want 2", len(got)) } - if got[0].ID != "IC_xyz" { - t.Errorf("got[0].ID = %q, want %q", got[0].ID, "IC_xyz") + if got[0].Metadata == nil || got[0].Metadata.ID != "IC_xyz" { + t.Errorf("got[0].Metadata.ID = %v, want %q", got[0].Metadata, "IC_xyz") } - if got[1].ID != "" { - t.Errorf("got[1].ID = %q, want empty", got[1].ID) + if got[1].Metadata != nil { + t.Errorf("got[1].Metadata = %+v, want nil", got[1].Metadata) } if got[1].Body != "my draft" { t.Errorf("got[1].Body = %q, want %q", got[1].Body, "my draft") From e039cab52033255b331325a8f2d229ec39046faf Mon Sep 17 00:00:00 2001 From: James Johns <192176108+jamesjohnsdev@users.noreply.github.com> Date: Sat, 20 Jun 2026 23:44:22 +1000 Subject: [PATCH 6/8] feat: run issues commands from subdirectories add recursive search for `.issues` directory to enable cli commands from any subdirectories of a parent directory that is initialised --- cmd/util.go | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/cmd/util.go b/cmd/util.go index 49b6608..8182b4b 100644 --- a/cmd/util.go +++ b/cmd/util.go @@ -18,11 +18,19 @@ func issuesRoot() (string, error) { if err != nil { return "", err } - path := filepath.Join(cwd, issuesDirName) - if _, err := os.Stat(path); os.IsNotExist(err) { - return "", errors.New("no .issues directory — run `issues init` first") + + for { + path := filepath.Join(cwd, issuesDirName) + if _, err := os.Stat(path); err == nil { + return path, nil + } + parentDir := filepath.Dir(cwd) + if parentDir == cwd { + break + } + cwd = parentDir } - return path, nil + return "", errors.New("no .issues directory — run `issues init` first") } func openDir(root string) string { return filepath.Join(root, "open") } From ae830d753b4c57b648b8798eee67a42889b962eb Mon Sep 17 00:00:00 2001 From: James Johns <192176108+jamesjohnsdev@users.noreply.github.com> Date: Sun, 21 Jun 2026 11:08:27 +1000 Subject: [PATCH 7/8] perf(sync): skip unchanged issues and parallelize comment fetches Use updatedAt from GitHub to skip issues where updatedAt <= syncedAt. Fan out comment fetches with 20 concurrent workers. Build local index once per sync instead of re-scanning on every pullOne call. Cold sync: ~500s -> 42s. Warm sync: ~500s -> 12s (list call only). --- cmd/pull.go | 50 ++++++++++++++++++++++++++++++++-------- cmd/push.go | 10 +++++++- cmd/sync.go | 51 +++++++++++++++++++++++++++++++++++++++-- cmd/util.go | 12 ++++++++++ internal/gh/client.go | 5 +++- internal/issue/issue.go | 4 ++++ 6 files changed, 118 insertions(+), 14 deletions(-) diff --git a/cmd/pull.go b/cmd/pull.go index d8603af..d63bfad 100644 --- a/cmd/pull.go +++ b/cmd/pull.go @@ -32,24 +32,55 @@ var pullCmd = &cobra.Command{ if err != nil { return err } - return pullOne(root, remote) + local, _ := findLocalByNumber(root, number) + localIndex := map[int]*issue.Issue{} + if local != nil { + localIndex[local.Number] = local + } + commentsPath, err := pullOne(root, remote, localIndex) + if err != nil { + return err + } + if commentsPath != "" { + return pullComments(remote, commentsPath) + } + return nil } + localIndex, err := buildLocalIndex(root) + if err != nil { + return err + } remotes, err := gh.ListAll() if err != nil { return err } for _, remote := range remotes { - if err := pullOne(root, remote); err != nil { + commentsPath, err := pullOne(root, remote, localIndex) + if err != nil { return err } + if commentsPath != "" { + if err := pullComments(remote, commentsPath); err != nil { + return err + } + } } fmt.Printf("%s %d issue(s)\n", color.GreenString("Pulled"), len(remotes)) return nil }, } -func pullOne(root string, iss *issue.Issue) error { +// pullOne writes the issue file and original snapshot. Returns commentsPath to +// be fetched, or empty string if the issue is unchanged since last sync. +func pullOne(root string, iss *issue.Issue, localIndex map[int]*issue.Issue) (string, error) { + existing := localIndex[iss.Number] + + // Skip if nothing has changed since we last synced. + if existing != nil && existing.SyncedAt != nil && iss.UpdatedAt != nil && !iss.UpdatedAt.After(*existing.SyncedAt) { + return "", nil + } + now := time.Now().UTC().Truncate(time.Second) iss.SyncedAt = &now @@ -57,30 +88,29 @@ func pullOne(root string, iss *issue.Issue) error { destPath := filepath.Join(dir, issue.Filename(iss)) commentsPath := filepath.Join(dir, issue.CommentsFilename(iss)) - // If the issue exists locally in a different location (e.g. state changed), remove the old files - existing, _ := findLocalByNumber(root, iss.Number) + // If state changed the file lives in a different directory; move old files. if existing != nil && existing.Path != destPath { if err := os.Remove(existing.Path); err != nil { - return fmt.Errorf("removing old local issue: %w", err) + return "", fmt.Errorf("removing old local issue: %w", err) } oldCommentsPath := filepath.Join(filepath.Dir(existing.Path), issue.CommentsFilename(existing)) if _, err := os.Stat(oldCommentsPath); err == nil { if err := os.Rename(oldCommentsPath, commentsPath); err != nil { - return fmt.Errorf("moving comments file: %w", err) + return "", fmt.Errorf("moving comments file: %w", err) } } } if err := issue.Write(destPath, iss); err != nil { - return fmt.Errorf("writing #%d: %w", iss.Number, err) + return "", fmt.Errorf("writing #%d: %w", iss.Number, err) } origPath := filepath.Join(originalsDir(root), fmt.Sprintf("%d.md", iss.Number)) if err := saveOriginal(destPath, origPath); err != nil { - return err + return "", err } - return pullComments(iss, commentsPath) + return commentsPath, nil } func pullComments(iss *issue.Issue, commentsPath string) error { diff --git a/cmd/push.go b/cmd/push.go index 3ce7075..2676c5f 100644 --- a/cmd/push.go +++ b/cmd/push.go @@ -85,7 +85,15 @@ func pushOne(root string, iss *issue.Issue) error { return err } fmt.Printf("%s #%d: %s\n", color.GreenString("Created"), number, iss.Title) - return pullOne(root, remote) + localIndex := map[int]*issue.Issue{iss.Number: iss} + commentsPath, err := pullOne(root, remote, localIndex) + if err != nil { + return err + } + if commentsPath != "" { + return pullComments(remote, commentsPath) + } + return nil } if err := gh.Update(iss); err != nil { diff --git a/cmd/sync.go b/cmd/sync.go index 98693e9..3999fb7 100644 --- a/cmd/sync.go +++ b/cmd/sync.go @@ -5,9 +5,11 @@ import ( "fmt" "os" "strings" + "sync" "github.com/fatih/color" "github.com/jamesjohnsdev/issues/internal/gh" + "github.com/jamesjohnsdev/issues/internal/issue" "github.com/spf13/cobra" ) @@ -79,12 +81,57 @@ var syncCmd = &cobra.Command{ if err != nil { return err } + + // Reload local index after any pushes above. + localIndex, err := buildLocalIndex(root) + if err != nil { + return err + } + + // Write issue files serially, collect which ones need comment fetches. + type commentJob struct { + iss *issue.Issue + commentsPath string + } + var jobs []commentJob for _, remote := range remotes { - if err := pullOne(root, remote); err != nil { + cp, err := pullOne(root, remote, localIndex) + if err != nil { return err } + if cp != "" { + jobs = append(jobs, commentJob{remote, cp}) + } + } + + // Fan out comment fetches — one gh subprocess per issue but now concurrent. + const concurrency = 20 + sem := make(chan struct{}, concurrency) + var wg sync.WaitGroup + errCh := make(chan error, len(jobs)) + for _, j := range jobs { + wg.Add(1) + sem <- struct{}{} + go func(j commentJob) { + defer wg.Done() + defer func() { <-sem }() + if err := pullComments(j.iss, j.commentsPath); err != nil { + errCh <- err + } + }(j) + } + wg.Wait() + close(errCh) + if err := <-errCh; err != nil { + return err + } + + skipped := len(remotes) - len(jobs) + if skipped > 0 { + fmt.Printf("%s %d issue(s) from GitHub (%d unchanged)\n", color.GreenString("Synced"), len(remotes), skipped) + } else { + fmt.Printf("%s %d issue(s) from GitHub\n", color.GreenString("Synced"), len(remotes)) } - fmt.Printf("%s %d issue(s) from GitHub\n", color.GreenString("Synced"), len(remotes)) return nil }, } diff --git a/cmd/util.go b/cmd/util.go index 8182b4b..c407186 100644 --- a/cmd/util.go +++ b/cmd/util.go @@ -81,6 +81,18 @@ func findLocalByNumber(root string, number int) (*issue.Issue, error) { return nil, fmt.Errorf("issue #%d not found locally", number) } +func buildLocalIndex(root string) (map[int]*issue.Issue, error) { + issues, err := loadAllLocal(root) + if err != nil { + return nil, err + } + m := make(map[int]*issue.Issue, len(issues)) + for _, iss := range issues { + m[iss.Number] = iss + } + return m, nil +} + // findLocalByID accepts either a plain integer ("42") or a T-prefixed local ID ("T1"). func findLocalByID(root, id string) (*issue.Issue, error) { issues, err := loadAllLocal(root) diff --git a/internal/gh/client.go b/internal/gh/client.go index c33d3ef..cf214bb 100644 --- a/internal/gh/client.go +++ b/internal/gh/client.go @@ -10,7 +10,7 @@ import ( "github.com/jamesjohnsdev/issues/internal/issue" ) -const jsonFields = "number,title,state,stateReason,body,labels,assignees,milestone" +const jsonFields = "number,title,state,stateReason,body,labels,assignees,milestone,updatedAt" const commentFields = "comments" type ghLabel struct { @@ -34,6 +34,7 @@ type ghIssue struct { Labels []ghLabel `json:"labels"` Assignees []ghUser `json:"assignees"` Milestone *ghMilestone `json:"milestone"` + UpdatedAt time.Time `json:"updatedAt"` } func (g ghIssue) toIssue() *issue.Issue { @@ -49,6 +50,7 @@ func (g ghIssue) toIssue() *issue.Issue { if g.Milestone != nil { milestone = g.Milestone.Title } + updatedAt := g.UpdatedAt return &issue.Issue{ Number: g.Number, Title: g.Title, @@ -58,6 +60,7 @@ func (g ghIssue) toIssue() *issue.Issue { State: strings.ToLower(g.State), StateReason: strings.ToLower(g.StateReason), Body: g.Body, + UpdatedAt: &updatedAt, } } diff --git a/internal/issue/issue.go b/internal/issue/issue.go index 36e93c2..17a0bc9 100644 --- a/internal/issue/issue.go +++ b/internal/issue/issue.go @@ -20,6 +20,10 @@ type Issue struct { StateReason string `yaml:"state_reason,omitempty"` SyncedAt *time.Time `yaml:"synced_at,omitempty"` + // UpdatedAt is populated from GitHub during sync; not persisted locally. + // Used to skip unchanged issues: if UpdatedAt <= SyncedAt nothing has changed. + UpdatedAt *time.Time `yaml:"-"` + Body string `yaml:"-"` Path string `yaml:"-"` } From da40e4f577fe70530e7b1b83283d10ee264e38df Mon Sep 17 00:00:00 2001 From: James Johns <192176108+jamesjohnsdev@users.noreply.github.com> Date: Sun, 21 Jun 2026 15:14:30 +1000 Subject: [PATCH 8/8] feat: merge issues together and mark duplicates - fixes #18 --- README.md | 1 + cmd/comment.go | 14 +- cmd/merge.go | 184 +++++++++++++++++++++++++++ cmd/merge_test.go | 288 ++++++++++++++++++++++++++++++++++++++++++ cmd/pull.go | 5 +- cmd/root.go | 1 + internal/gh/client.go | 12 ++ 7 files changed, 502 insertions(+), 3 deletions(-) create mode 100644 cmd/merge.go create mode 100644 cmd/merge_test.go diff --git a/README.md b/README.md index 9aebfa8..81c9893 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,7 @@ also for AI augmented workflows, where agents can have a source for development. - `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. diff --git a/cmd/comment.go b/cmd/comment.go index 279b34a..0a3af58 100644 --- a/cmd/comment.go +++ b/cmd/comment.go @@ -40,8 +40,18 @@ var commentCmd = &cobra.Command{ return fmt.Errorf("creating temp file: %w", err) } tmpPath := tmp.Name() - defer os.Remove(tmpPath) - tmp.Close() + 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)...) diff --git a/cmd/merge.go b/cmd/merge.go new file mode 100644 index 0000000..72738bc --- /dev/null +++ b/cmd/merge.go @@ -0,0 +1,184 @@ +package cmd + +import ( + "bufio" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/fatih/color" + "github.com/jamesjohnsdev/issues/internal/gh" + "github.com/jamesjohnsdev/issues/internal/issue" + "github.com/spf13/cobra" +) + +var mergeCmd = &cobra.Command{ + Use: "merge <a> <b>", + Short: "Close issue a as a duplicate of issue b", + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + root, err := issuesRoot() + if err != nil { + return err + } + + if args[0] == args[1] { + return fmt.Errorf("cannot merge an issue into itself: %q", args[0]) + } + + issA, err := findLocalByID(root, args[0]) + if err != nil { + return err + } + issB, err := findLocalByID(root, args[1]) + if err != nil { + return err + } + + if issA.Path == issB.Path { + return fmt.Errorf("cannot merge an issue into itself: %q", args[0]) + } + + aOnline := issA.Number != 0 + bOnline := issB.Number != 0 + + switch { + case aOnline && bOnline: + return mergeBothOnline(root, issA, issB) + case !aOnline && bOnline: + return mergeLocalOnline(issA, issB) + case aOnline && !bOnline: + return mergeOnlineLocal(root, issA, issB) + default: + return mergeBothLocal(root, issA, issB) + } + }, +} + +// mergeBothOnline: both issues are on GitHub. +// Closes a with a "duplicate of b" comment, adds a note to b, updates local state. +func mergeBothOnline(root string, issA, issB *issue.Issue) error { + if err := gh.Close(issA.Number, fmt.Sprintf("Duplicate of #%d", issB.Number)); err != nil { + return err + } + if err := gh.AddComment(issB.Number, fmt.Sprintf("Closed #%d as a duplicate of this issue.", issA.Number)); err != nil { + return err + } + + issA.State = "closed" + issA.StateReason = "not_planned" + newPath := filepath.Join(closedDir(root), filepath.Base(issA.Path)) + if err := os.MkdirAll(closedDir(root), 0755); err != nil { + return err + } + if err := issue.Write(newPath, issA); err != nil { + return err + } + if err := os.Remove(issA.Path); err != nil { + return err + } + commentsPath := filepath.Join(filepath.Dir(issA.Path), issue.CommentsFilename(issA)) + if err := os.Rename(commentsPath, filepath.Join(closedDir(root), filepath.Base(commentsPath))); err != nil { + return err + } + + fmt.Printf("%s #%d (%s) → #%d (%s)\n", + color.GreenString("Merged"), issA.Number, issA.Title, issB.Number, issB.Title) + return nil +} + +// mergeLocalOnline: a is local-only, b is on GitHub. +// Adds a's content as a comment on b, then deletes a locally. +func mergeLocalOnline(issA, issB *issue.Issue) error { + aID := idFromPath(issA.Path) + body := fmt.Sprintf("Merged local issue %s: **%s**\n\n%s", aID, issA.Title, issA.Body) + if err := gh.AddComment(issB.Number, body); err != nil { + return err + } + + commentsPath := filepath.Join(filepath.Dir(issA.Path), issue.CommentsFilename(issA)) + _ = os.Remove(commentsPath) + if err := os.Remove(issA.Path); err != nil { + return err + } + + fmt.Printf("%s %s (%s) → #%d (%s), deleted locally\n", + color.GreenString("Merged"), aID, issA.Title, issB.Number, issB.Title) + return nil +} + +// mergeOnlineLocal: a is on GitHub, b is local-only. +// Prompts for confirmation, adds a's content as a local comment on b, deletes a from GitHub. +func mergeOnlineLocal(root string, issA, issB *issue.Issue) error { + bID := idFromPath(issB.Path) + fmt.Printf("Issue #%d (%s) is on GitHub and will be deleted from GitHub.\n", issA.Number, issA.Title) + fmt.Print("Proceed? [y/N] ") + + reader := bufio.NewReader(os.Stdin) + answer, _ := reader.ReadString('\n') + if answer = strings.TrimSpace(strings.ToLower(answer)); answer != "y" && answer != "yes" { + fmt.Println(color.YellowString("Aborted.")) + return nil + } + + commentBody := fmt.Sprintf("Merged from GitHub #%d: **%s**\n\n%s", issA.Number, issA.Title, issA.Body) + commentsPath := filepath.Join(filepath.Dir(issB.Path), issue.CommentsFilename(issB)) + comments, err := issue.ParseComments(commentsPath) + if err != nil { + return fmt.Errorf("reading comments: %w", err) + } + comments = append(comments, &issue.Comment{Body: commentBody}) + if err := issue.WriteComments(commentsPath, comments); err != nil { + return fmt.Errorf("saving comment: %w", err) + } + + if err := gh.Delete(issA.Number); err != nil { + return err + } + origPath := filepath.Join(originalsDir(root), fmt.Sprintf("%d.md", issA.Number)) + _ = os.Remove(origPath) + if err := os.Remove(issA.Path); err != nil { + return err + } + + fmt.Printf("%s #%d (%s) → %s (%s), deleted from GitHub\n", + color.GreenString("Merged"), issA.Number, issA.Title, bID, issB.Title) + return nil +} + +// mergeBothLocal: both issues are local-only. +// Adds b's reference as a comment on a, then closes a locally. +func mergeBothLocal(root string, issA, issB *issue.Issue) error { + aID := idFromPath(issA.Path) + bID := idFromPath(issB.Path) + + commentBody := fmt.Sprintf("Closed as duplicate of %s: **%s**", bID, issB.Title) + commentsPath := filepath.Join(filepath.Dir(issA.Path), issue.CommentsFilename(issA)) + comments, err := issue.ParseComments(commentsPath) + if err != nil { + return fmt.Errorf("reading comments: %w", err) + } + comments = append(comments, &issue.Comment{Body: commentBody}) + if err := issue.WriteComments(commentsPath, comments); err != nil { + return fmt.Errorf("saving comment: %w", err) + } + + issA.State = "closed" + newPath := filepath.Join(closedDir(root), filepath.Base(issA.Path)) + if err := os.MkdirAll(closedDir(root), 0755); err != nil { + return err + } + if err := issue.Write(newPath, issA); err != nil { + return err + } + if err := os.Remove(issA.Path); err != nil { + return err + } + newCommentsPath := filepath.Join(closedDir(root), filepath.Base(commentsPath)) + _ = os.Rename(commentsPath, newCommentsPath) + + fmt.Printf("%s %s (%s) → %s (%s)\n", + color.GreenString("Merged"), aID, issA.Title, bID, issB.Title) + return nil +} diff --git a/cmd/merge_test.go b/cmd/merge_test.go new file mode 100644 index 0000000..d9c4fb1 --- /dev/null +++ b/cmd/merge_test.go @@ -0,0 +1,288 @@ +package cmd + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/jamesjohnsdev/issues/internal/issue" +) + +func TestMergeBothLocal(t *testing.T) { + t.Run("closes a in closed dir with state closed", func(t *testing.T) { + parent := makeProjectDir(t, []issueFixture{ + {"T1-issue-a.md", issue.Issue{Title: "Issue A", State: "open"}}, + {"T2-issue-b.md", issue.Issue{Title: "Issue B", State: "open"}}, + }) + chdirTo(t, parent) + issuesDir := filepath.Join(parent, issuesDirName) + + _ = captureStdout(t, func() { + if err := mergeCmd.RunE(mergeCmd, []string{"T1", "T2"}); err != nil { + t.Fatalf("unexpected error: %v", err) + } + }) + + closedPath := filepath.Join(issuesDir, "closed", "T1-issue-a.md") + iss, err := issue.Parse(closedPath) + if err != nil { + t.Fatalf("parsing closed issue: %v", err) + } + if iss.State != "closed" { + t.Errorf("State = %q, want closed", iss.State) + } + }) + + t.Run("removes a from open dir", func(t *testing.T) { + parent := makeProjectDir(t, []issueFixture{ + {"T1-issue-a.md", issue.Issue{Title: "Issue A", State: "open"}}, + {"T2-issue-b.md", issue.Issue{Title: "Issue B", State: "open"}}, + }) + chdirTo(t, parent) + issuesDir := filepath.Join(parent, issuesDirName) + + _ = captureStdout(t, func() { + if err := mergeCmd.RunE(mergeCmd, []string{"T1", "T2"}); err != nil { + t.Fatalf("unexpected error: %v", err) + } + }) + + if _, err := os.Stat(filepath.Join(issuesDir, "open", "T1-issue-a.md")); !os.IsNotExist(err) { + t.Error("expected T1-issue-a.md to be removed from open/") + } + }) + + t.Run("leaves b unchanged in open dir", func(t *testing.T) { + parent := makeProjectDir(t, []issueFixture{ + {"T1-issue-a.md", issue.Issue{Title: "Issue A", State: "open"}}, + {"T2-issue-b.md", issue.Issue{Title: "Issue B", State: "open"}}, + }) + chdirTo(t, parent) + issuesDir := filepath.Join(parent, issuesDirName) + + _ = captureStdout(t, func() { + if err := mergeCmd.RunE(mergeCmd, []string{"T1", "T2"}); err != nil { + t.Fatalf("unexpected error: %v", err) + } + }) + + if _, err := os.Stat(filepath.Join(issuesDir, "open", "T2-issue-b.md")); os.IsNotExist(err) { + t.Error("expected T2-issue-b.md to remain in open/") + } + }) + + t.Run("writes duplicate comment referencing b on a's comments file", func(t *testing.T) { + parent := makeProjectDir(t, []issueFixture{ + {"T1-issue-a.md", issue.Issue{Title: "Issue A", State: "open"}}, + {"T2-issue-b.md", issue.Issue{Title: "Issue B", State: "open"}}, + }) + chdirTo(t, parent) + issuesDir := filepath.Join(parent, issuesDirName) + + _ = captureStdout(t, func() { + if err := mergeCmd.RunE(mergeCmd, []string{"T1", "T2"}); err != nil { + t.Fatalf("unexpected error: %v", err) + } + }) + + commentsPath := filepath.Join(issuesDir, "closed", "0-issue-a.comments.json") + comments, err := issue.ParseComments(commentsPath) + if err != nil { + t.Fatalf("ParseComments: %v", err) + } + if len(comments) != 1 { + t.Fatalf("expected 1 comment, got %d", len(comments)) + } + body := comments[0].Body + if !strings.Contains(body, "T2") { + t.Errorf("comment body %q should reference T2", body) + } + if !strings.Contains(body, "Issue B") { + t.Errorf("comment body %q should reference b's title", body) + } + }) + + t.Run("comment has nil metadata (local draft)", func(t *testing.T) { + parent := makeProjectDir(t, []issueFixture{ + {"T1-issue-a.md", issue.Issue{Title: "Issue A", State: "open"}}, + {"T2-issue-b.md", issue.Issue{Title: "Issue B", State: "open"}}, + }) + chdirTo(t, parent) + issuesDir := filepath.Join(parent, issuesDirName) + + _ = captureStdout(t, func() { + if err := mergeCmd.RunE(mergeCmd, []string{"T1", "T2"}); err != nil { + t.Fatalf("unexpected error: %v", err) + } + }) + + commentsPath := filepath.Join(issuesDir, "closed", "0-issue-a.comments.json") + comments, err := issue.ParseComments(commentsPath) + if err != nil { + t.Fatalf("ParseComments: %v", err) + } + if len(comments) == 0 { + t.Fatal("no comments") + } + if comments[0].Metadata != nil { + t.Errorf("expected nil Metadata for local draft comment, got %+v", comments[0].Metadata) + } + }) + + t.Run("comments file moved to closed dir", func(t *testing.T) { + parent := makeProjectDir(t, []issueFixture{ + {"T1-issue-a.md", issue.Issue{Title: "Issue A", State: "open"}}, + {"T2-issue-b.md", issue.Issue{Title: "Issue B", State: "open"}}, + }) + chdirTo(t, parent) + issuesDir := filepath.Join(parent, issuesDirName) + + _ = captureStdout(t, func() { + if err := mergeCmd.RunE(mergeCmd, []string{"T1", "T2"}); err != nil { + t.Fatalf("unexpected error: %v", err) + } + }) + + if _, err := os.Stat(filepath.Join(issuesDir, "open", "0-issue-a.comments.json")); !os.IsNotExist(err) { + t.Error("expected comments file to be removed from open/") + } + if _, err := os.Stat(filepath.Join(issuesDir, "closed", "0-issue-a.comments.json")); os.IsNotExist(err) { + t.Error("expected comments file to exist in closed/") + } + }) + + t.Run("preserves pre-existing comments on a", func(t *testing.T) { + parent := makeProjectDir(t, []issueFixture{ + {"T1-issue-a.md", issue.Issue{Title: "Issue A", State: "open"}}, + {"T2-issue-b.md", issue.Issue{Title: "Issue B", State: "open"}}, + }) + chdirTo(t, parent) + issuesDir := filepath.Join(parent, issuesDirName) + + existing := []*issue.Comment{{Body: "pre-existing comment"}} + commentsFile := filepath.Join(issuesDir, "open", "0-issue-a.comments.json") + if err := issue.WriteComments(commentsFile, existing); err != nil { + t.Fatalf("WriteComments: %v", err) + } + + _ = captureStdout(t, func() { + if err := mergeCmd.RunE(mergeCmd, []string{"T1", "T2"}); err != nil { + t.Fatalf("unexpected error: %v", err) + } + }) + + closedComments := filepath.Join(issuesDir, "closed", "0-issue-a.comments.json") + comments, err := issue.ParseComments(closedComments) + if err != nil { + t.Fatalf("ParseComments: %v", err) + } + if len(comments) != 2 { + t.Fatalf("expected 2 comments (existing + merge note), got %d", len(comments)) + } + if comments[0].Body != "pre-existing comment" { + t.Errorf("first comment = %q, want pre-existing comment", comments[0].Body) + } + }) + + t.Run("case-insensitive T-ids", func(t *testing.T) { + parent := makeProjectDir(t, []issueFixture{ + {"T1-issue-a.md", issue.Issue{Title: "Issue A", State: "open"}}, + {"T2-issue-b.md", issue.Issue{Title: "Issue B", State: "open"}}, + }) + chdirTo(t, parent) + issuesDir := filepath.Join(parent, issuesDirName) + + _ = captureStdout(t, func() { + if err := mergeCmd.RunE(mergeCmd, []string{"t1", "t2"}); err != nil { + t.Fatalf("unexpected error: %v", err) + } + }) + + if _, err := os.Stat(filepath.Join(issuesDir, "closed", "T1-issue-a.md")); os.IsNotExist(err) { + t.Error("expected T1-issue-a.md in closed/ after lowercase t-id merge") + } + }) +} + +func TestMergeErrors(t *testing.T) { + t.Run("unknown issue a returns error", func(t *testing.T) { + parent := makeProjectDir(t, []issueFixture{ + {"T2-issue-b.md", issue.Issue{Title: "Issue B", State: "open"}}, + }) + chdirTo(t, parent) + + err := mergeCmd.RunE(mergeCmd, []string{"T1", "T2"}) + if err == nil { + t.Error("expected error for unknown issue a, got nil") + } + }) + + t.Run("unknown issue b returns error", func(t *testing.T) { + parent := makeProjectDir(t, []issueFixture{ + {"T1-issue-a.md", issue.Issue{Title: "Issue A", State: "open"}}, + }) + chdirTo(t, parent) + + err := mergeCmd.RunE(mergeCmd, []string{"T1", "T2"}) + if err == nil { + t.Error("expected error for unknown issue b, got nil") + } + }) + + t.Run("invalid issue id returns error", func(t *testing.T) { + parent := makeProjectDir(t, nil) + chdirTo(t, parent) + + err := mergeCmd.RunE(mergeCmd, []string{"abc", "T2"}) + if err == nil { + t.Error("expected error for invalid id, got nil") + } + }) +} + +func TestMergeOnlineLocalAbort(t *testing.T) { + t.Run("answering N leaves both issues unchanged", func(t *testing.T) { + parent := makeProjectDir(t, []issueFixture{ + {"5-github-issue.md", issue.Issue{Number: 5, Title: "GitHub issue", State: "open"}}, + {"T1-local-issue.md", issue.Issue{Title: "Local issue", State: "open"}}, + }) + chdirTo(t, parent) + issuesDir := filepath.Join(parent, issuesDirName) + injectStdin(t, "n\n") + + _ = captureStdout(t, func() { + if err := mergeCmd.RunE(mergeCmd, []string{"5", "T1"}); err != nil { + t.Fatalf("unexpected error: %v", err) + } + }) + + if _, err := os.Stat(filepath.Join(issuesDir, "open", "5-github-issue.md")); os.IsNotExist(err) { + t.Error("expected GitHub issue to remain in open/ after abort") + } + commentsPath := filepath.Join(issuesDir, "open", "0-local-issue.comments.json") + if _, err := os.Stat(commentsPath); err == nil { + t.Error("expected no comments file to be created after abort") + } + }) + + t.Run("empty answer is treated as N", func(t *testing.T) { + parent := makeProjectDir(t, []issueFixture{ + {"5-github-issue.md", issue.Issue{Number: 5, Title: "GitHub issue", State: "open"}}, + {"T1-local-issue.md", issue.Issue{Title: "Local issue", State: "open"}}, + }) + chdirTo(t, parent) + issuesDir := filepath.Join(parent, issuesDirName) + injectStdin(t, "\n") + + _ = captureStdout(t, func() { + if err := mergeCmd.RunE(mergeCmd, []string{"5", "T1"}); err != nil { + t.Fatalf("unexpected error: %v", err) + } + }) + + if _, err := os.Stat(filepath.Join(issuesDir, "open", "5-github-issue.md")); os.IsNotExist(err) { + t.Error("expected GitHub issue to remain in open/ after empty-answer abort") + } + }) +} diff --git a/cmd/pull.go b/cmd/pull.go index d63bfad..1a0e3fb 100644 --- a/cmd/pull.go +++ b/cmd/pull.go @@ -120,7 +120,10 @@ func pullComments(iss *issue.Issue, commentsPath string) error { } // Preserve any local-only comments (those without an id) by merging them in - local, _ := issue.ParseComments(commentsPath) + local, err := issue.ParseComments(commentsPath) + if err != nil { + return fmt.Errorf("parsing local comments: %w", err) + } remoteIDs := make(map[string]bool, len(remote)) for _, c := range remote { if c.Metadata != nil { diff --git a/cmd/root.go b/cmd/root.go index 798a0d5..7c9eb81 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -70,4 +70,5 @@ Use "{{.CommandPath}} [command] --help" for more information about a command.{{e rootCmd.AddCommand(commentCmd) rootCmd.AddCommand(closeCmd) rootCmd.AddCommand(deleteCmd) + rootCmd.AddCommand(mergeCmd) } diff --git a/internal/gh/client.go b/internal/gh/client.go index cf214bb..8df37f8 100644 --- a/internal/gh/client.go +++ b/internal/gh/client.go @@ -218,6 +218,18 @@ func AddComment(number int, body string) error { return nil } +// Close closes a GitHub issue as "not planned", optionally with a comment. +func Close(number int, comment string) error { + args := []string{"issue", "close", fmt.Sprintf("%d", number), "--reason", "not planned"} + if comment != "" { + args = append(args, "--comment", comment) + } + if _, err := run("gh", args...); err != nil { + return fmt.Errorf("gh issue close %d: %w", number, err) + } + return nil +} + // Delete permanently deletes a GitHub issue. func Delete(number int) error { if _, err := run("gh", "issue", "delete", fmt.Sprintf("%d", number), "--yes"); err != nil {