67 files changed,
+4200,
-5
+3,
-0
1@@ -6,3 +6,6 @@ public/
2 testdata.site/
3 testdata.repo/
4 docs/plans/
5+# Only ignore 'bug' at root level, not in cmd/bug/
6+/bug
7+bug-test
+54,
-0
1@@ -121,6 +121,60 @@ Remove the hook:
2
3 This will restore any previous post-commit hook if one existed.
4
5+## editing issues and comments
6+
7+The `bug edit` command allows you to modify existing issues and comments.
8+
9+### Edit an issue
10+
11+Update the title, description, or both:
12+
13+```bash
14+# Open editor with current content
15+bug edit abc1234
16+
17+# Update only the title
18+bug edit abc1234 --title "New Title"
19+bug edit abc1234 -t "New Title"
20+
21+# Update only the description
22+bug edit abc1234 --message "New description"
23+bug edit abc1234 -m "New description"
24+
25+# Update both title and description
26+bug edit abc1234 -t "New Title" -m "New description"
27+```
28+
29+### Edit a comment
30+
31+```bash
32+# Open editor with current comment text
33+bug edit def5678
34+
35+# Update comment directly
36+bug edit def5678 -m "Updated comment text"
37+```
38+
39+When using the editor:
40+- For issues: edit the title on the first line, leave a blank line, then edit the description
41+- For comments: edit the comment text directly
42+- Lines starting with `;;` are ignored (instructions)
43+
44+### Agent edit command
45+
46+For automated/agent use, the `bug agent edit` command is non-interactive and
47+requires at least one of `--title` or `--message`:
48+
49+```bash
50+# Edit an issue as agent
51+bug agent edit abc1234 --title "New Title"
52+bug agent edit abc1234 --message "New description"
53+bug agent edit abc1234 --title "Title" --message "Description"
54+
55+# Edit a comment as agent (requires --message)
56+bug agent edit def5678 --message "Updated comment"
57+```
58+
59 ## inspiration
60
61 This project was heavily inspired by
+493,
-0
1@@ -0,0 +1,493 @@
2+//go:build integration
3+
4+package main
5+
6+import (
7+ "os/exec"
8+ "strings"
9+ "testing"
10+
11+ "github.com/git-bug/git-bug/entities/bug"
12+ "github.com/git-bug/git-bug/entity"
13+ "github.com/git-bug/git-bug/repository"
14+)
15+
16+// TestAgentCommentCommand_WithFlag adds a comment as agent using the message flag
17+func TestAgentCommentCommand_WithFlag(t *testing.T) {
18+ tmpDir := t.TempDir()
19+
20+ // Initialize a git repo
21+ initCmd := exec.Command("git", "init")
22+ initCmd.Dir = tmpDir
23+ if err := initCmd.Run(); err != nil {
24+ t.Fatalf("failed to init git repo: %v", err)
25+ }
26+
27+ // Initialize git-bug with both user and agent identities
28+ if err := createIdentity(tmpDir, "Test User", "[email protected]", true); err != nil {
29+ t.Fatalf("failed to create user identity: %v", err)
30+ }
31+ if err := createIdentity(tmpDir, "agent", "", false); err != nil {
32+ t.Fatalf("failed to create agent identity: %v", err)
33+ }
34+
35+ // Create a bug first as user
36+ title := "Test Bug for Agent Comment"
37+ message := "Initial bug description"
38+ if err := runNew(tmpDir, title, message); err != nil {
39+ t.Fatalf("runNew failed: %v", err)
40+ }
41+
42+ // Get the bug ID
43+ repo, err := repository.OpenGoGitRepo(tmpDir, "", nil)
44+ if err != nil {
45+ t.Fatalf("failed to open repo: %v", err)
46+ }
47+
48+ var bugID string
49+ for streamedBug := range bug.ReadAll(repo) {
50+ if streamedBug.Err != nil {
51+ t.Fatalf("failed to read bug: %v", streamedBug.Err)
52+ }
53+ b := streamedBug.Entity
54+ snap := b.Compile()
55+ if snap.Title == title {
56+ bugID = b.Id().String()
57+ break
58+ }
59+ }
60+ repo.Close()
61+
62+ if bugID == "" {
63+ t.Fatal("could not find created bug")
64+ }
65+
66+ // Add a comment as agent using the flag
67+ commentText := "This is an automated agent comment"
68+ if err := runAgentComment(tmpDir, bugID, commentText); err != nil {
69+ t.Fatalf("runAgentComment failed: %v", err)
70+ }
71+
72+ // Verify comment was added with agent as author
73+ repo, err = repository.OpenGoGitRepo(tmpDir, "", nil)
74+ if err != nil {
75+ t.Fatalf("failed to open repo: %v", err)
76+ }
77+ defer repo.Close()
78+
79+ b, err := bug.Read(repo, entity.Id(bugID))
80+ if err != nil {
81+ t.Fatalf("failed to read bug: %v", err)
82+ }
83+
84+ snap := b.Compile()
85+ if len(snap.Comments) != 2 {
86+ t.Errorf("expected 2 comments (original + new), got %d", len(snap.Comments))
87+ }
88+
89+ // Check the second comment (index 1) is our agent comment
90+ if snap.Comments[1].Message != commentText {
91+ t.Errorf("comment message = %q, want %q", snap.Comments[1].Message, commentText)
92+ }
93+
94+ if snap.Comments[1].Author.Name() != "agent" {
95+ t.Errorf("comment author = %q, want %q", snap.Comments[1].Author.Name(), "agent")
96+ }
97+
98+ if snap.Comments[1].Author.Email() != "" {
99+ t.Errorf("comment author email = %q, want empty", snap.Comments[1].Author.Email())
100+ }
101+}
102+
103+// TestAgentCommentCommand_WithShortID adds a comment as agent using short bug ID
104+func TestAgentCommentCommand_WithShortID(t *testing.T) {
105+ tmpDir := t.TempDir()
106+
107+ // Initialize a git repo
108+ initCmd := exec.Command("git", "init")
109+ initCmd.Dir = tmpDir
110+ if err := initCmd.Run(); err != nil {
111+ t.Fatalf("failed to init git repo: %v", err)
112+ }
113+
114+ // Initialize identities
115+ if err := createIdentity(tmpDir, "Test User", "[email protected]", true); err != nil {
116+ t.Fatalf("failed to create user identity: %v", err)
117+ }
118+ if err := createIdentity(tmpDir, "agent", "", false); err != nil {
119+ t.Fatalf("failed to create agent identity: %v", err)
120+ }
121+
122+ // Create a bug
123+ title := "Test Bug for Agent Short ID"
124+ if err := runNew(tmpDir, title, ""); err != nil {
125+ t.Fatalf("runNew failed: %v", err)
126+ }
127+
128+ // Get the bug ID and short ID
129+ repo, err := repository.OpenGoGitRepo(tmpDir, "", nil)
130+ if err != nil {
131+ t.Fatalf("failed to open repo: %v", err)
132+ }
133+
134+ var bugID, shortID string
135+ for streamedBug := range bug.ReadAll(repo) {
136+ if streamedBug.Err != nil {
137+ t.Fatalf("failed to read bug: %v", streamedBug.Err)
138+ }
139+ b := streamedBug.Entity
140+ snap := b.Compile()
141+ if snap.Title == title {
142+ bugID = b.Id().String()
143+ if len(bugID) > 7 {
144+ shortID = bugID[:7]
145+ } else {
146+ shortID = bugID
147+ }
148+ break
149+ }
150+ }
151+ repo.Close()
152+
153+ if shortID == "" {
154+ t.Fatal("could not find created bug")
155+ }
156+
157+ // Add a comment as agent using short ID
158+ commentText := "Agent comment via short ID"
159+ if err := runAgentComment(tmpDir, shortID, commentText); err != nil {
160+ t.Fatalf("runAgentComment with short ID failed: %v", err)
161+ }
162+
163+ // Verify comment was added
164+ repo, err = repository.OpenGoGitRepo(tmpDir, "", nil)
165+ if err != nil {
166+ t.Fatalf("failed to open repo: %v", err)
167+ }
168+ defer repo.Close()
169+
170+ b, err := bug.Read(repo, entity.Id(bugID))
171+ if err != nil {
172+ t.Fatalf("failed to read bug: %v", err)
173+ }
174+
175+ snap := b.Compile()
176+ if len(snap.Comments) != 2 {
177+ t.Errorf("expected 2 comments, got %d", len(snap.Comments))
178+ }
179+
180+ if snap.Comments[1].Message != commentText {
181+ t.Errorf("comment message = %q, want %q", snap.Comments[1].Message, commentText)
182+ }
183+
184+ if snap.Comments[1].Author.Name() != "agent" {
185+ t.Errorf("comment author = %q, want %q", snap.Comments[1].Author.Name(), "agent")
186+ }
187+}
188+
189+// TestAgentCommentCommand_MissingAgentIdentity tests error when agent not initialized
190+func TestAgentCommentCommand_MissingAgentIdentity(t *testing.T) {
191+ tmpDir := t.TempDir()
192+
193+ // Initialize a git repo
194+ initCmd := exec.Command("git", "init")
195+ initCmd.Dir = tmpDir
196+ if err := initCmd.Run(); err != nil {
197+ t.Fatalf("failed to init git repo: %v", err)
198+ }
199+
200+ // Only create user identity, NOT agent
201+ if err := createIdentity(tmpDir, "Test User", "[email protected]", true); err != nil {
202+ t.Fatalf("failed to create user identity: %v", err)
203+ }
204+
205+ // Create a bug as user
206+ title := "Test Bug"
207+ if err := runNew(tmpDir, title, ""); err != nil {
208+ t.Fatalf("runNew failed: %v", err)
209+ }
210+
211+ // Get the bug ID
212+ repo, err := repository.OpenGoGitRepo(tmpDir, "", nil)
213+ if err != nil {
214+ t.Fatalf("failed to open repo: %v", err)
215+ }
216+
217+ var bugID string
218+ for streamedBug := range bug.ReadAll(repo) {
219+ if streamedBug.Err != nil {
220+ t.Fatalf("failed to read bug: %v", streamedBug.Err)
221+ }
222+ b := streamedBug.Entity
223+ snap := b.Compile()
224+ if snap.Title == title {
225+ bugID = b.Id().String()
226+ break
227+ }
228+ }
229+ repo.Close()
230+
231+ // Try to add a comment as agent (should fail)
232+ err = runAgentComment(tmpDir, bugID, "Test comment")
233+ if err == nil {
234+ t.Error("expected error when agent identity not found, got nil")
235+ }
236+
237+ if !strings.Contains(err.Error(), "agent identity not found") {
238+ t.Errorf("expected 'agent identity not found' error, got: %v", err)
239+ }
240+}
241+
242+// TestAgentCommentCommand_EmptyMessageError tests error for empty message
243+func TestAgentCommentCommand_EmptyMessageError(t *testing.T) {
244+ tmpDir := t.TempDir()
245+
246+ // Initialize a git repo
247+ initCmd := exec.Command("git", "init")
248+ initCmd.Dir = tmpDir
249+ if err := initCmd.Run(); err != nil {
250+ t.Fatalf("failed to init git repo: %v", err)
251+ }
252+
253+ // Initialize identities
254+ if err := createIdentity(tmpDir, "Test User", "[email protected]", true); err != nil {
255+ t.Fatalf("failed to create user identity: %v", err)
256+ }
257+ if err := createIdentity(tmpDir, "agent", "", false); err != nil {
258+ t.Fatalf("failed to create agent identity: %v", err)
259+ }
260+
261+ // Create a bug
262+ title := "Test Bug"
263+ if err := runNew(tmpDir, title, ""); err != nil {
264+ t.Fatalf("runNew failed: %v", err)
265+ }
266+
267+ // Get the bug ID
268+ repo, err := repository.OpenGoGitRepo(tmpDir, "", nil)
269+ if err != nil {
270+ t.Fatalf("failed to open repo: %v", err)
271+ }
272+
273+ var bugID string
274+ for streamedBug := range bug.ReadAll(repo) {
275+ if streamedBug.Err != nil {
276+ t.Fatalf("failed to read bug: %v", streamedBug.Err)
277+ }
278+ b := streamedBug.Entity
279+ snap := b.Compile()
280+ if snap.Title == title {
281+ bugID = b.Id().String()
282+ break
283+ }
284+ }
285+ repo.Close()
286+
287+ // Try with empty message
288+ err = runAgentComment(tmpDir, bugID, " ")
289+ if err == nil {
290+ t.Error("expected error for empty message, got nil")
291+ }
292+
293+ if !strings.Contains(err.Error(), "message cannot be empty") {
294+ t.Errorf("expected 'message cannot be empty' error, got: %v", err)
295+ }
296+}
297+
298+// TestAgentCommentCommand_InvalidBugID tests error for invalid bug ID
299+func TestAgentCommentCommand_InvalidBugID(t *testing.T) {
300+ tmpDir := t.TempDir()
301+
302+ // Initialize a git repo
303+ initCmd := exec.Command("git", "init")
304+ initCmd.Dir = tmpDir
305+ if err := initCmd.Run(); err != nil {
306+ t.Fatalf("failed to init git repo: %v", err)
307+ }
308+
309+ // Initialize identities
310+ if err := createIdentity(tmpDir, "Test User", "[email protected]", true); err != nil {
311+ t.Fatalf("failed to create user identity: %v", err)
312+ }
313+ if err := createIdentity(tmpDir, "agent", "", false); err != nil {
314+ t.Fatalf("failed to create agent identity: %v", err)
315+ }
316+
317+ // Try to add a comment to non-existent bug
318+ err := runAgentComment(tmpDir, "nonexistent", "Test comment")
319+ if err == nil {
320+ t.Error("expected error for invalid bug ID, got nil")
321+ }
322+
323+ if !strings.Contains(err.Error(), "failed to resolve bug ID") {
324+ t.Errorf("expected 'failed to resolve bug ID' error, got: %v", err)
325+ }
326+}
327+
328+// TestAgentCommentCommand_MultipleComments tests adding multiple agent comments
329+func TestAgentCommentCommand_MultipleComments(t *testing.T) {
330+ tmpDir := t.TempDir()
331+
332+ // Initialize a git repo
333+ initCmd := exec.Command("git", "init")
334+ initCmd.Dir = tmpDir
335+ if err := initCmd.Run(); err != nil {
336+ t.Fatalf("failed to init git repo: %v", err)
337+ }
338+
339+ // Initialize identities
340+ if err := createIdentity(tmpDir, "Test User", "[email protected]", true); err != nil {
341+ t.Fatalf("failed to create user identity: %v", err)
342+ }
343+ if err := createIdentity(tmpDir, "agent", "", false); err != nil {
344+ t.Fatalf("failed to create agent identity: %v", err)
345+ }
346+
347+ // Create a bug as user
348+ title := "Test Bug for Multiple Agent Comments"
349+ if err := runNew(tmpDir, title, ""); err != nil {
350+ t.Fatalf("runNew failed: %v", err)
351+ }
352+
353+ // Get the bug ID
354+ repo, err := repository.OpenGoGitRepo(tmpDir, "", nil)
355+ if err != nil {
356+ t.Fatalf("failed to open repo: %v", err)
357+ }
358+
359+ var bugID string
360+ for streamedBug := range bug.ReadAll(repo) {
361+ if streamedBug.Err != nil {
362+ t.Fatalf("failed to read bug: %v", streamedBug.Err)
363+ }
364+ b := streamedBug.Entity
365+ snap := b.Compile()
366+ if snap.Title == title {
367+ bugID = b.Id().String()
368+ break
369+ }
370+ }
371+ repo.Close()
372+
373+ // Add multiple comments as agent
374+ comments := []string{
375+ "First agent comment",
376+ "Second agent comment",
377+ "Third agent comment with **markdown**",
378+ }
379+
380+ for _, comment := range comments {
381+ if err := runAgentComment(tmpDir, bugID, comment); err != nil {
382+ t.Fatalf("runAgentComment failed: %v", err)
383+ }
384+ }
385+
386+ // Verify all comments were added
387+ repo, err = repository.OpenGoGitRepo(tmpDir, "", nil)
388+ if err != nil {
389+ t.Fatalf("failed to open repo: %v", err)
390+ }
391+ defer repo.Close()
392+
393+ b, err := bug.Read(repo, entity.Id(bugID))
394+ if err != nil {
395+ t.Fatalf("failed to read bug: %v", err)
396+ }
397+
398+ snap := b.Compile()
399+ // Original + 3 comments = 4 total
400+ if len(snap.Comments) != 4 {
401+ t.Errorf("expected 4 comments, got %d", len(snap.Comments))
402+ }
403+
404+ // Verify each comment (index 1-3 are our added comments)
405+ for i, expected := range comments {
406+ if snap.Comments[i+1].Message != expected {
407+ t.Errorf("comment %d message = %q, want %q", i+1, snap.Comments[i+1].Message, expected)
408+ }
409+ if snap.Comments[i+1].Author.Name() != "agent" {
410+ t.Errorf("comment %d author = %q, want %q", i+1, snap.Comments[i+1].Author.Name(), "agent")
411+ }
412+ }
413+}
414+
415+// TestAgentCommentCommand_WithMarkdown tests that markdown is preserved
416+func TestAgentCommentCommand_WithMarkdown(t *testing.T) {
417+ tmpDir := t.TempDir()
418+
419+ // Initialize a git repo
420+ initCmd := exec.Command("git", "init")
421+ initCmd.Dir = tmpDir
422+ if err := initCmd.Run(); err != nil {
423+ t.Fatalf("failed to init git repo: %v", err)
424+ }
425+
426+ // Initialize identities
427+ if err := createIdentity(tmpDir, "Test User", "[email protected]", true); err != nil {
428+ t.Fatalf("failed to create user identity: %v", err)
429+ }
430+ if err := createIdentity(tmpDir, "agent", "", false); err != nil {
431+ t.Fatalf("failed to create agent identity: %v", err)
432+ }
433+
434+ // Create a bug
435+ title := "Test Bug for Markdown"
436+ if err := runNew(tmpDir, title, ""); err != nil {
437+ t.Fatalf("runNew failed: %v", err)
438+ }
439+
440+ // Get the bug ID
441+ repo, err := repository.OpenGoGitRepo(tmpDir, "", nil)
442+ if err != nil {
443+ t.Fatalf("failed to open repo: %v", err)
444+ }
445+
446+ var bugID string
447+ for streamedBug := range bug.ReadAll(repo) {
448+ if streamedBug.Err != nil {
449+ t.Fatalf("failed to read bug: %v", streamedBug.Err)
450+ }
451+ b := streamedBug.Entity
452+ snap := b.Compile()
453+ if snap.Title == title {
454+ bugID = b.Id().String()
455+ break
456+ }
457+ }
458+ repo.Close()
459+
460+ // Add a comment with markdown
461+ markdownComment := `# Analysis Report
462+
463+## Summary
464+- **Status**: PASSED
465+- **Duration**: 5m 32s
466+
467+## Details
468+All tests passed successfully.`
469+
470+ if err := runAgentComment(tmpDir, bugID, markdownComment); err != nil {
471+ t.Fatalf("runAgentComment failed: %v", err)
472+ }
473+
474+ // Verify comment was added with markdown preserved
475+ repo, err = repository.OpenGoGitRepo(tmpDir, "", nil)
476+ if err != nil {
477+ t.Fatalf("failed to open repo: %v", err)
478+ }
479+ defer repo.Close()
480+
481+ b, err := bug.Read(repo, entity.Id(bugID))
482+ if err != nil {
483+ t.Fatalf("failed to read bug: %v", err)
484+ }
485+
486+ snap := b.Compile()
487+ if len(snap.Comments) != 2 {
488+ t.Errorf("expected 2 comments, got %d", len(snap.Comments))
489+ }
490+
491+ if snap.Comments[1].Message != markdownComment {
492+ t.Errorf("comment message does not match expected markdown:\ngot:\n%s\nwant:\n%s", snap.Comments[1].Message, markdownComment)
493+ }
494+}
+370,
-0
1@@ -0,0 +1,370 @@
2+//go:build integration
3+
4+package main
5+
6+import (
7+ "os/exec"
8+ "strings"
9+ "testing"
10+
11+ "github.com/git-bug/git-bug/entities/bug"
12+ "github.com/git-bug/git-bug/entity"
13+ "github.com/git-bug/git-bug/repository"
14+)
15+
16+// TestAgentEditCommand_EditIssue edits an issue as the agent
17+func TestAgentEditCommand_EditIssue(t *testing.T) {
18+ tmpDir := t.TempDir()
19+
20+ // Initialize a git repo
21+ initCmd := exec.Command("git", "init")
22+ initCmd.Dir = tmpDir
23+ if err := initCmd.Run(); err != nil {
24+ t.Fatalf("failed to init git repo: %v", err)
25+ }
26+
27+ // Initialize git-bug identities (creates both user and agent)
28+ if err := runInitWithReader(tmpDir, strings.NewReader("Test User\[email protected]\n")); err != nil {
29+ t.Fatalf("runInit failed: %v", err)
30+ }
31+
32+ // Create a bug as agent
33+ if err := runAgentNew(tmpDir, "Original Title", "Original description"); err != nil {
34+ t.Fatalf("runAgentNew failed: %v", err)
35+ }
36+
37+ // Get the bug ID
38+ repo, err := repository.OpenGoGitRepo(tmpDir, "", nil)
39+ if err != nil {
40+ t.Fatalf("failed to open repo: %v", err)
41+ }
42+
43+ var bugID string
44+ for streamedBug := range bug.ReadAll(repo) {
45+ if streamedBug.Err != nil {
46+ t.Fatalf("failed to read bug: %v", streamedBug.Err)
47+ }
48+ b := streamedBug.Entity
49+ snap := b.Compile()
50+ if snap.Title == "Original Title" {
51+ bugID = b.Id().String()
52+ break
53+ }
54+ }
55+ repo.Close()
56+
57+ if bugID == "" {
58+ t.Fatal("could not find created bug")
59+ }
60+
61+ // Edit as agent
62+ newTitle := "Agent Updated Title"
63+ newMessage := "Agent updated description"
64+ if err := runAgentEdit(tmpDir, bugID, newTitle, newMessage); err != nil {
65+ t.Fatalf("runAgentEdit failed: %v", err)
66+ }
67+
68+ // Verify both were updated
69+ repo, err = repository.OpenGoGitRepo(tmpDir, "", nil)
70+ if err != nil {
71+ t.Fatalf("failed to open repo: %v", err)
72+ }
73+ defer repo.Close()
74+
75+ b, err := bug.Read(repo, entity.Id(bugID))
76+ if err != nil {
77+ t.Fatalf("failed to read bug: %v", err)
78+ }
79+
80+ snap := b.Compile()
81+ if snap.Title != newTitle {
82+ t.Errorf("title = %q, want %q", snap.Title, newTitle)
83+ }
84+ if len(snap.Comments) == 0 || snap.Comments[0].Message != newMessage {
85+ t.Errorf("description = %q, want %q", snap.Comments[0].Message, newMessage)
86+ }
87+}
88+
89+// TestAgentEditCommand_EditComment edits a comment as the agent
90+func TestAgentEditCommand_EditComment(t *testing.T) {
91+ tmpDir := t.TempDir()
92+
93+ // Initialize a git repo
94+ initCmd := exec.Command("git", "init")
95+ initCmd.Dir = tmpDir
96+ if err := initCmd.Run(); err != nil {
97+ t.Fatalf("failed to init git repo: %v", err)
98+ }
99+
100+ // Initialize git-bug identities
101+ if err := runInitWithReader(tmpDir, strings.NewReader("Test User\[email protected]\n")); err != nil {
102+ t.Fatalf("runInit failed: %v", err)
103+ }
104+
105+ // Create a bug as agent
106+ if err := runAgentNew(tmpDir, "Test Bug", "Description"); err != nil {
107+ t.Fatalf("runAgentNew failed: %v", err)
108+ }
109+
110+ // Get the bug ID
111+ repo, err := repository.OpenGoGitRepo(tmpDir, "", nil)
112+ if err != nil {
113+ t.Fatalf("failed to open repo: %v", err)
114+ }
115+
116+ var bugID string
117+ for streamedBug := range bug.ReadAll(repo) {
118+ if streamedBug.Err != nil {
119+ t.Fatalf("failed to read bug: %v", streamedBug.Err)
120+ }
121+ b := streamedBug.Entity
122+ snap := b.Compile()
123+ if snap.Title == "Test Bug" {
124+ bugID = b.Id().String()
125+ break
126+ }
127+ }
128+ repo.Close()
129+
130+ // Add a comment as agent
131+ originalComment := "Original agent comment"
132+ if err := runAgentComment(tmpDir, bugID, originalComment); err != nil {
133+ t.Fatalf("runAgentComment failed: %v", err)
134+ }
135+
136+ // Get the comment ID
137+ repo, err = repository.OpenGoGitRepo(tmpDir, "", nil)
138+ if err != nil {
139+ t.Fatalf("failed to open repo: %v", err)
140+ }
141+
142+ b, err := bug.Read(repo, entity.Id(bugID))
143+ if err != nil {
144+ t.Fatalf("failed to read bug: %v", err)
145+ }
146+ snap := b.Compile()
147+ if len(snap.Comments) < 2 {
148+ t.Fatal("expected at least 2 comments")
149+ }
150+ commentID := snap.Comments[1].CombinedId().String()
151+ repo.Close()
152+
153+ // Edit the comment as agent
154+ newComment := "Updated agent comment"
155+ if err := runAgentEdit(tmpDir, commentID, "", newComment); err != nil {
156+ t.Fatalf("runAgentEdit failed: %v", err)
157+ }
158+
159+ // Verify comment was updated
160+ repo, err = repository.OpenGoGitRepo(tmpDir, "", nil)
161+ if err != nil {
162+ t.Fatalf("failed to open repo: %v", err)
163+ }
164+ defer repo.Close()
165+
166+ b, err = bug.Read(repo, entity.Id(bugID))
167+ if err != nil {
168+ t.Fatalf("failed to read bug: %v", err)
169+ }
170+
171+ snap = b.Compile()
172+ if len(snap.Comments) < 2 {
173+ t.Fatal("expected at least 2 comments")
174+ }
175+ if snap.Comments[1].Message != newComment {
176+ t.Errorf("comment = %q, want %q", snap.Comments[1].Message, newComment)
177+ }
178+}
179+
180+// TestAgentEditCommand_RequiresMessage tests that agent edit requires at least one field
181+func TestAgentEditCommand_RequiresMessage(t *testing.T) {
182+ tmpDir := t.TempDir()
183+
184+ // Initialize a git repo
185+ initCmd := exec.Command("git", "init")
186+ initCmd.Dir = tmpDir
187+ if err := initCmd.Run(); err != nil {
188+ t.Fatalf("failed to init git repo: %v", err)
189+ }
190+
191+ // Initialize git-bug identities
192+ if err := runInitWithReader(tmpDir, strings.NewReader("Test User\[email protected]\n")); err != nil {
193+ t.Fatalf("runInit failed: %v", err)
194+ }
195+
196+ // Create a bug as agent
197+ if err := runAgentNew(tmpDir, "Test Bug", "Description"); err != nil {
198+ t.Fatalf("runAgentNew failed: %v", err)
199+ }
200+
201+ // Get the bug ID
202+ repo, err := repository.OpenGoGitRepo(tmpDir, "", nil)
203+ if err != nil {
204+ t.Fatalf("failed to open repo: %v", err)
205+ }
206+
207+ var bugID string
208+ for streamedBug := range bug.ReadAll(repo) {
209+ if streamedBug.Err != nil {
210+ t.Fatalf("failed to read bug: %v", streamedBug.Err)
211+ }
212+ b := streamedBug.Entity
213+ snap := b.Compile()
214+ if snap.Title == "Test Bug" {
215+ bugID = b.Id().String()
216+ break
217+ }
218+ }
219+ repo.Close()
220+
221+ // Add a comment as agent
222+ if err := runAgentComment(tmpDir, bugID, "Original comment"); err != nil {
223+ t.Fatalf("runAgentComment failed: %v", err)
224+ }
225+
226+ // Get the comment ID
227+ repo, err = repository.OpenGoGitRepo(tmpDir, "", nil)
228+ if err != nil {
229+ t.Fatalf("failed to open repo: %v", err)
230+ }
231+
232+ b, err := bug.Read(repo, entity.Id(bugID))
233+ if err != nil {
234+ t.Fatalf("failed to read bug: %v", err)
235+ }
236+ snap := b.Compile()
237+ commentID := snap.Comments[1].CombinedId().String()
238+ repo.Close()
239+
240+ // Try to edit comment without message
241+ err = runAgentEdit(tmpDir, commentID, "", "")
242+ if err == nil {
243+ t.Error("expected error when editing comment without message, got nil")
244+ }
245+
246+ if !strings.Contains(err.Error(), "either --title or --message") {
247+ t.Errorf("expected 'either --title or --message' error, got: %v", err)
248+ }
249+}
250+
251+// TestAgentEditCommand_RequiresAtLeastOneField tests that agent edit requires at least one field
252+func TestAgentEditCommand_RequiresAtLeastOneField(t *testing.T) {
253+ tmpDir := t.TempDir()
254+
255+ // Initialize a git repo
256+ initCmd := exec.Command("git", "init")
257+ initCmd.Dir = tmpDir
258+ if err := initCmd.Run(); err != nil {
259+ t.Fatalf("failed to init git repo: %v", err)
260+ }
261+
262+ // Initialize git-bug identities
263+ if err := runInitWithReader(tmpDir, strings.NewReader("Test User\[email protected]\n")); err != nil {
264+ t.Fatalf("runInit failed: %v", err)
265+ }
266+
267+ // Create a bug as agent
268+ if err := runAgentNew(tmpDir, "Test Bug", "Description"); err != nil {
269+ t.Fatalf("runAgentNew failed: %v", err)
270+ }
271+
272+ // Get the bug ID
273+ repo, err := repository.OpenGoGitRepo(tmpDir, "", nil)
274+ if err != nil {
275+ t.Fatalf("failed to open repo: %v", err)
276+ }
277+
278+ var bugID string
279+ for streamedBug := range bug.ReadAll(repo) {
280+ if streamedBug.Err != nil {
281+ t.Fatalf("failed to read bug: %v", streamedBug.Err)
282+ }
283+ b := streamedBug.Entity
284+ snap := b.Compile()
285+ if snap.Title == "Test Bug" {
286+ bugID = b.Id().String()
287+ break
288+ }
289+ }
290+ repo.Close()
291+
292+ // Try to edit without any flags
293+ err = runAgentEdit(tmpDir, bugID, "", "")
294+ if err == nil {
295+ t.Error("expected error when editing without any fields, got nil")
296+ }
297+
298+ if !strings.Contains(err.Error(), "either --title or --message") {
299+ t.Errorf("expected 'either --title or --message' error, got: %v", err)
300+ }
301+}
302+
303+// TestAgentEditCommand_OnlyTitle edits only the title as agent
304+func TestAgentEditCommand_OnlyTitle(t *testing.T) {
305+ tmpDir := t.TempDir()
306+
307+ // Initialize a git repo
308+ initCmd := exec.Command("git", "init")
309+ initCmd.Dir = tmpDir
310+ if err := initCmd.Run(); err != nil {
311+ t.Fatalf("failed to init git repo: %v", err)
312+ }
313+
314+ // Initialize git-bug identities
315+ if err := runInitWithReader(tmpDir, strings.NewReader("Test User\[email protected]\n")); err != nil {
316+ t.Fatalf("runInit failed: %v", err)
317+ }
318+
319+ // Create a bug as agent
320+ originalTitle := "Original Title"
321+ originalDesc := "Original description"
322+ if err := runAgentNew(tmpDir, originalTitle, originalDesc); err != nil {
323+ t.Fatalf("runAgentNew failed: %v", err)
324+ }
325+
326+ // Get the bug ID
327+ repo, err := repository.OpenGoGitRepo(tmpDir, "", nil)
328+ if err != nil {
329+ t.Fatalf("failed to open repo: %v", err)
330+ }
331+
332+ var bugID string
333+ for streamedBug := range bug.ReadAll(repo) {
334+ if streamedBug.Err != nil {
335+ t.Fatalf("failed to read bug: %v", streamedBug.Err)
336+ }
337+ b := streamedBug.Entity
338+ snap := b.Compile()
339+ if snap.Title == originalTitle {
340+ bugID = b.Id().String()
341+ break
342+ }
343+ }
344+ repo.Close()
345+
346+ // Edit only title as agent
347+ newTitle := "New Title"
348+ if err := runAgentEdit(tmpDir, bugID, newTitle, ""); err != nil {
349+ t.Fatalf("runAgentEdit failed: %v", err)
350+ }
351+
352+ // Verify only title changed
353+ repo, err = repository.OpenGoGitRepo(tmpDir, "", nil)
354+ if err != nil {
355+ t.Fatalf("failed to open repo: %v", err)
356+ }
357+ defer repo.Close()
358+
359+ b, err := bug.Read(repo, entity.Id(bugID))
360+ if err != nil {
361+ t.Fatalf("failed to read bug: %v", err)
362+ }
363+
364+ snap := b.Compile()
365+ if snap.Title != newTitle {
366+ t.Errorf("title = %q, want %q", snap.Title, newTitle)
367+ }
368+ if len(snap.Comments) == 0 || snap.Comments[0].Message != originalDesc {
369+ t.Errorf("description was changed unexpectedly")
370+ }
371+}
+233,
-0
1@@ -0,0 +1,233 @@
2+//go:build integration
3+
4+package main
5+
6+import (
7+ "os/exec"
8+ "strings"
9+ "testing"
10+
11+ "github.com/git-bug/git-bug/entities/bug"
12+ "github.com/git-bug/git-bug/repository"
13+)
14+
15+// TestAgentNewCommand_WithFlags creates a bug as agent using flags
16+func TestAgentNewCommand_WithFlags(t *testing.T) {
17+ tmpDir := t.TempDir()
18+
19+ // Initialize a git repo
20+ initCmd := exec.Command("git", "init")
21+ initCmd.Dir = tmpDir
22+ if err := initCmd.Run(); err != nil {
23+ t.Fatalf("failed to init git repo: %v", err)
24+ }
25+
26+ // Initialize git-bug with both user and agent identities
27+ if err := createIdentity(tmpDir, "Test User", "[email protected]", true); err != nil {
28+ t.Fatalf("failed to create user identity: %v", err)
29+ }
30+ if err := createIdentity(tmpDir, "agent", "", false); err != nil {
31+ t.Fatalf("failed to create agent identity: %v", err)
32+ }
33+
34+ // Create a bug as agent using flags
35+ title := "Agent Created Bug"
36+ message := "This bug was created by an automated agent"
37+ if err := runAgentNew(tmpDir, title, message); err != nil {
38+ t.Fatalf("runAgentNew failed: %v", err)
39+ }
40+
41+ // Verify bug was created with agent as author
42+ repo, err := repository.OpenGoGitRepo(tmpDir, "", nil)
43+ if err != nil {
44+ t.Fatalf("failed to open repo: %v", err)
45+ }
46+ defer repo.Close()
47+
48+ // Count bugs
49+ bugCount := 0
50+ for streamedBug := range bug.ReadAll(repo) {
51+ if streamedBug.Err != nil {
52+ t.Fatalf("failed to read bug: %v", streamedBug.Err)
53+ }
54+ b := streamedBug.Entity
55+ snap := b.Compile()
56+
57+ if snap.Title == title {
58+ bugCount++
59+ if snap.Comments[0].Message != message {
60+ t.Errorf("message = %q, want %q", snap.Comments[0].Message, message)
61+ }
62+ if snap.Author.Name() != "agent" {
63+ t.Errorf("author = %q, want %q", snap.Author.Name(), "agent")
64+ }
65+ if snap.Author.Email() != "" {
66+ t.Errorf("author email = %q, want empty", snap.Author.Email())
67+ }
68+ }
69+ }
70+
71+ if bugCount != 1 {
72+ t.Errorf("expected 1 bug with title %q, found %d", title, bugCount)
73+ }
74+}
75+
76+// TestAgentNewCommand_MissingAgentIdentity tests error when agent not initialized
77+func TestAgentNewCommand_MissingAgentIdentity(t *testing.T) {
78+ tmpDir := t.TempDir()
79+
80+ // Initialize a git repo
81+ initCmd := exec.Command("git", "init")
82+ initCmd.Dir = tmpDir
83+ if err := initCmd.Run(); err != nil {
84+ t.Fatalf("failed to init git repo: %v", err)
85+ }
86+
87+ // Only create user identity, NOT agent
88+ if err := createIdentity(tmpDir, "Test User", "[email protected]", true); err != nil {
89+ t.Fatalf("failed to create user identity: %v", err)
90+ }
91+
92+ // Try to create a bug as agent (should fail)
93+ err := runAgentNew(tmpDir, "Title", "Message")
94+ if err == nil {
95+ t.Error("expected error when agent identity not found, got nil")
96+ }
97+
98+ if !strings.Contains(err.Error(), "agent identity not found") {
99+ t.Errorf("expected 'agent identity not found' error, got: %v", err)
100+ }
101+}
102+
103+// TestAgentNewCommand_EmptyTitleError tests error for empty title
104+func TestAgentNewCommand_EmptyTitleError(t *testing.T) {
105+ tmpDir := t.TempDir()
106+
107+ // Initialize a git repo
108+ initCmd := exec.Command("git", "init")
109+ initCmd.Dir = tmpDir
110+ if err := initCmd.Run(); err != nil {
111+ t.Fatalf("failed to init git repo: %v", err)
112+ }
113+
114+ // Initialize identities
115+ if err := createIdentity(tmpDir, "Test User", "[email protected]", true); err != nil {
116+ t.Fatalf("failed to create user identity: %v", err)
117+ }
118+ if err := createIdentity(tmpDir, "agent", "", false); err != nil {
119+ t.Fatalf("failed to create agent identity: %v", err)
120+ }
121+
122+ // Try with empty title
123+ err := runAgentNew(tmpDir, " ", "Message")
124+ if err == nil {
125+ t.Error("expected error for empty title, got nil")
126+ }
127+
128+ if !strings.Contains(err.Error(), "title cannot be empty") {
129+ t.Errorf("expected 'title cannot be empty' error, got: %v", err)
130+ }
131+}
132+
133+// TestAgentNewCommand_EmptyMessageError tests error for empty message
134+func TestAgentNewCommand_EmptyMessageError(t *testing.T) {
135+ tmpDir := t.TempDir()
136+
137+ // Initialize a git repo
138+ initCmd := exec.Command("git", "init")
139+ initCmd.Dir = tmpDir
140+ if err := initCmd.Run(); err != nil {
141+ t.Fatalf("failed to init git repo: %v", err)
142+ }
143+
144+ // Initialize identities
145+ if err := createIdentity(tmpDir, "Test User", "[email protected]", true); err != nil {
146+ t.Fatalf("failed to create user identity: %v", err)
147+ }
148+ if err := createIdentity(tmpDir, "agent", "", false); err != nil {
149+ t.Fatalf("failed to create agent identity: %v", err)
150+ }
151+
152+ // Try with empty message
153+ err := runAgentNew(tmpDir, "Title", " ")
154+ if err == nil {
155+ t.Error("expected error for empty message, got nil")
156+ }
157+
158+ if !strings.Contains(err.Error(), "message cannot be empty") {
159+ t.Errorf("expected 'message cannot be empty' error, got: %v", err)
160+ }
161+}
162+
163+// TestGetAgentIdentity_Success tests retrieving agent identity
164+func TestGetAgentIdentity_Success(t *testing.T) {
165+ tmpDir := t.TempDir()
166+
167+ // Initialize a git repo
168+ initCmd := exec.Command("git", "init")
169+ initCmd.Dir = tmpDir
170+ if err := initCmd.Run(); err != nil {
171+ t.Fatalf("failed to init git repo: %v", err)
172+ }
173+
174+ // Create user and agent identities
175+ if err := createIdentity(tmpDir, "Test User", "[email protected]", true); err != nil {
176+ t.Fatalf("failed to create user identity: %v", err)
177+ }
178+ if err := createIdentity(tmpDir, "agent", "", false); err != nil {
179+ t.Fatalf("failed to create agent identity: %v", err)
180+ }
181+
182+ // Open repo and get agent identity
183+ repo, err := repository.OpenGoGitRepo(tmpDir, "", nil)
184+ if err != nil {
185+ t.Fatalf("failed to open repo: %v", err)
186+ }
187+ defer repo.Close()
188+
189+ agent, err := getAgentIdentity(repo)
190+ if err != nil {
191+ t.Fatalf("getAgentIdentity failed: %v", err)
192+ }
193+
194+ if agent.Name() != "agent" {
195+ t.Errorf("agent name = %q, want %q", agent.Name(), "agent")
196+ }
197+
198+ if agent.Email() != "" {
199+ t.Errorf("agent email = %q, want empty", agent.Email())
200+ }
201+}
202+
203+// TestGetAgentIdentity_NotFound tests error when agent doesn't exist
204+func TestGetAgentIdentity_NotFound(t *testing.T) {
205+ tmpDir := t.TempDir()
206+
207+ // Initialize a git repo
208+ initCmd := exec.Command("git", "init")
209+ initCmd.Dir = tmpDir
210+ if err := initCmd.Run(); err != nil {
211+ t.Fatalf("failed to init git repo: %v", err)
212+ }
213+
214+ // Only create user identity
215+ if err := createIdentity(tmpDir, "Test User", "[email protected]", true); err != nil {
216+ t.Fatalf("failed to create user identity: %v", err)
217+ }
218+
219+ // Open repo and try to get agent identity
220+ repo, err := repository.OpenGoGitRepo(tmpDir, "", nil)
221+ if err != nil {
222+ t.Fatalf("failed to open repo: %v", err)
223+ }
224+ defer repo.Close()
225+
226+ _, err = getAgentIdentity(repo)
227+ if err == nil {
228+ t.Error("expected error when agent identity not found, got nil")
229+ }
230+
231+ if !strings.Contains(err.Error(), "agent identity not found") {
232+ t.Errorf("expected 'agent identity not found' error, got: %v", err)
233+ }
234+}
+360,
-0
1@@ -0,0 +1,360 @@
2+//go:build integration
3+
4+package main
5+
6+import (
7+ "os/exec"
8+ "strings"
9+ "testing"
10+
11+ "github.com/git-bug/git-bug/entities/bug"
12+ "github.com/git-bug/git-bug/entity"
13+ "github.com/git-bug/git-bug/repository"
14+)
15+
16+// TestCommentCommand_WithFlag adds a comment using the message flag
17+func TestCommentCommand_WithFlag(t *testing.T) {
18+ tmpDir := t.TempDir()
19+
20+ // Initialize a git repo
21+ initCmd := exec.Command("git", "init")
22+ initCmd.Dir = tmpDir
23+ if err := initCmd.Run(); err != nil {
24+ t.Fatalf("failed to init git repo: %v", err)
25+ }
26+
27+ // Initialize git-bug identities
28+ if err := createIdentity(tmpDir, "Test User", "[email protected]", true); err != nil {
29+ t.Fatalf("failed to create identity: %v", err)
30+ }
31+
32+ // Create a bug first
33+ title := "Test Bug for Comment"
34+ message := "Initial bug description"
35+ if err := runNew(tmpDir, title, message); err != nil {
36+ t.Fatalf("runNew failed: %v", err)
37+ }
38+
39+ // Get the bug ID
40+ repo, err := repository.OpenGoGitRepo(tmpDir, "", nil)
41+ if err != nil {
42+ t.Fatalf("failed to open repo: %v", err)
43+ }
44+
45+ var bugID string
46+ for streamedBug := range bug.ReadAll(repo) {
47+ if streamedBug.Err != nil {
48+ t.Fatalf("failed to read bug: %v", streamedBug.Err)
49+ }
50+ b := streamedBug.Entity
51+ snap := b.Compile()
52+ if snap.Title == title {
53+ bugID = b.Id().String()
54+ break
55+ }
56+ }
57+ repo.Close()
58+
59+ if bugID == "" {
60+ t.Fatal("could not find created bug")
61+ }
62+
63+ // Add a comment using the flag
64+ commentText := "This is a test comment"
65+ if err := runComment(tmpDir, bugID, commentText); err != nil {
66+ t.Fatalf("runComment failed: %v", err)
67+ }
68+
69+ // Verify comment was added
70+ repo, err = repository.OpenGoGitRepo(tmpDir, "", nil)
71+ if err != nil {
72+ t.Fatalf("failed to open repo: %v", err)
73+ }
74+ defer repo.Close()
75+
76+ b, err := bug.Read(repo, entity.Id(bugID))
77+ if err != nil {
78+ t.Fatalf("failed to read bug: %v", err)
79+ }
80+
81+ snap := b.Compile()
82+ if len(snap.Comments) != 2 {
83+ t.Errorf("expected 2 comments (original + new), got %d", len(snap.Comments))
84+ }
85+
86+ // Check the second comment (index 1) is our new comment
87+ if snap.Comments[1].Message != commentText {
88+ t.Errorf("comment message = %q, want %q", snap.Comments[1].Message, commentText)
89+ }
90+
91+ if snap.Comments[1].Author.Name() != "Test User" {
92+ t.Errorf("comment author = %q, want %q", snap.Comments[1].Author.Name(), "Test User")
93+ }
94+}
95+
96+// TestCommentCommand_WithShortID adds a comment using a short bug ID
97+func TestCommentCommand_WithShortID(t *testing.T) {
98+ tmpDir := t.TempDir()
99+
100+ // Initialize a git repo
101+ initCmd := exec.Command("git", "init")
102+ initCmd.Dir = tmpDir
103+ if err := initCmd.Run(); err != nil {
104+ t.Fatalf("failed to init git repo: %v", err)
105+ }
106+
107+ // Initialize git-bug identities
108+ if err := createIdentity(tmpDir, "Test User", "[email protected]", true); err != nil {
109+ t.Fatalf("failed to create identity: %v", err)
110+ }
111+
112+ // Create a bug
113+ title := "Test Bug for Short ID"
114+ if err := runNew(tmpDir, title, ""); err != nil {
115+ t.Fatalf("runNew failed: %v", err)
116+ }
117+
118+ // Get the bug ID and short ID
119+ repo, err := repository.OpenGoGitRepo(tmpDir, "", nil)
120+ if err != nil {
121+ t.Fatalf("failed to open repo: %v", err)
122+ }
123+
124+ var bugID, shortID string
125+ for streamedBug := range bug.ReadAll(repo) {
126+ if streamedBug.Err != nil {
127+ t.Fatalf("failed to read bug: %v", streamedBug.Err)
128+ }
129+ b := streamedBug.Entity
130+ snap := b.Compile()
131+ if snap.Title == title {
132+ bugID = b.Id().String()
133+ // Use first 7 chars as short ID
134+ if len(bugID) > 7 {
135+ shortID = bugID[:7]
136+ } else {
137+ shortID = bugID
138+ }
139+ break
140+ }
141+ }
142+ repo.Close()
143+
144+ if shortID == "" {
145+ t.Fatal("could not find created bug")
146+ }
147+
148+ // Add a comment using short ID
149+ commentText := "Comment added via short ID"
150+ if err := runComment(tmpDir, shortID, commentText); err != nil {
151+ t.Fatalf("runComment with short ID failed: %v", err)
152+ }
153+
154+ // Verify comment was added
155+ repo, err = repository.OpenGoGitRepo(tmpDir, "", nil)
156+ if err != nil {
157+ t.Fatalf("failed to open repo: %v", err)
158+ }
159+ defer repo.Close()
160+
161+ b, err := bug.Read(repo, entity.Id(bugID))
162+ if err != nil {
163+ t.Fatalf("failed to read bug: %v", err)
164+ }
165+
166+ snap := b.Compile()
167+ if len(snap.Comments) != 2 {
168+ t.Errorf("expected 2 comments, got %d", len(snap.Comments))
169+ }
170+
171+ if snap.Comments[1].Message != commentText {
172+ t.Errorf("comment message = %q, want %q", snap.Comments[1].Message, commentText)
173+ }
174+}
175+
176+// TestCommentCommand_NoIdentityError tests error when identity not set
177+func TestCommentCommand_NoIdentityError(t *testing.T) {
178+ tmpDir := t.TempDir()
179+
180+ // Initialize a git repo (but no git-bug identities)
181+ initCmd := exec.Command("git", "init")
182+ initCmd.Dir = tmpDir
183+ if err := initCmd.Run(); err != nil {
184+ t.Fatalf("failed to init git repo: %v", err)
185+ }
186+
187+ // Don't create any identities - try to add a comment to non-existent bug
188+ // Bug ID resolution happens before identity check, so we should get ID error first
189+ err := runComment(tmpDir, "nonexistent123", "Test comment")
190+ if err == nil {
191+ t.Error("expected error, got nil")
192+ }
193+
194+ // Since no bugs exist at all, we should get an ID resolution error
195+ if !strings.Contains(err.Error(), "failed to resolve bug ID") {
196+ t.Errorf("expected 'failed to resolve bug ID' error, got: %v", err)
197+ }
198+}
199+
200+// TestCommentCommand_EmptyMessageError tests error for empty message
201+func TestCommentCommand_EmptyMessageError(t *testing.T) {
202+ tmpDir := t.TempDir()
203+
204+ // Initialize a git repo
205+ initCmd := exec.Command("git", "init")
206+ initCmd.Dir = tmpDir
207+ if err := initCmd.Run(); err != nil {
208+ t.Fatalf("failed to init git repo: %v", err)
209+ }
210+
211+ // Initialize git-bug identities
212+ if err := createIdentity(tmpDir, "Test User", "[email protected]", true); err != nil {
213+ t.Fatalf("failed to create identity: %v", err)
214+ }
215+
216+ // Create a bug first
217+ title := "Test Bug"
218+ if err := runNew(tmpDir, title, ""); err != nil {
219+ t.Fatalf("runNew failed: %v", err)
220+ }
221+
222+ // Get the bug ID
223+ repo, err := repository.OpenGoGitRepo(tmpDir, "", nil)
224+ if err != nil {
225+ t.Fatalf("failed to open repo: %v", err)
226+ }
227+
228+ var bugID string
229+ for streamedBug := range bug.ReadAll(repo) {
230+ if streamedBug.Err != nil {
231+ t.Fatalf("failed to read bug: %v", streamedBug.Err)
232+ }
233+ b := streamedBug.Entity
234+ snap := b.Compile()
235+ if snap.Title == title {
236+ bugID = b.Id().String()
237+ break
238+ }
239+ }
240+ repo.Close()
241+
242+ // Try to add a comment with empty message (whitespace only)
243+ // This triggers the editor path, which returns "no comment provided"
244+ err = runComment(tmpDir, bugID, " ")
245+ if err == nil {
246+ t.Error("expected error for empty message, got nil")
247+ }
248+
249+ // When empty message is provided, editor opens and returns "no comment provided"
250+ if !strings.Contains(err.Error(), "no comment provided") {
251+ t.Errorf("expected 'no comment provided' error, got: %v", err)
252+ }
253+}
254+
255+// TestCommentCommand_InvalidBugID tests error for invalid bug ID
256+func TestCommentCommand_InvalidBugID(t *testing.T) {
257+ tmpDir := t.TempDir()
258+
259+ // Initialize a git repo
260+ initCmd := exec.Command("git", "init")
261+ initCmd.Dir = tmpDir
262+ if err := initCmd.Run(); err != nil {
263+ t.Fatalf("failed to init git repo: %v", err)
264+ }
265+
266+ // Initialize git-bug identities
267+ if err := createIdentity(tmpDir, "Test User", "[email protected]", true); err != nil {
268+ t.Fatalf("failed to create identity: %v", err)
269+ }
270+
271+ // Try to add a comment to non-existent bug
272+ err := runComment(tmpDir, "nonexistent", "Test comment")
273+ if err == nil {
274+ t.Error("expected error for invalid bug ID, got nil")
275+ }
276+
277+ if !strings.Contains(err.Error(), "failed to resolve bug ID") {
278+ t.Errorf("expected 'failed to resolve bug ID' error, got: %v", err)
279+ }
280+}
281+
282+// TestCommentCommand_MultipleComments tests adding multiple comments
283+func TestCommentCommand_MultipleComments(t *testing.T) {
284+ tmpDir := t.TempDir()
285+
286+ // Initialize a git repo
287+ initCmd := exec.Command("git", "init")
288+ initCmd.Dir = tmpDir
289+ if err := initCmd.Run(); err != nil {
290+ t.Fatalf("failed to init git repo: %v", err)
291+ }
292+
293+ // Initialize git-bug identities
294+ if err := createIdentity(tmpDir, "Test User", "[email protected]", true); err != nil {
295+ t.Fatalf("failed to create identity: %v", err)
296+ }
297+
298+ // Create a bug
299+ title := "Test Bug for Multiple Comments"
300+ if err := runNew(tmpDir, title, ""); err != nil {
301+ t.Fatalf("runNew failed: %v", err)
302+ }
303+
304+ // Get the bug ID
305+ repo, err := repository.OpenGoGitRepo(tmpDir, "", nil)
306+ if err != nil {
307+ t.Fatalf("failed to open repo: %v", err)
308+ }
309+
310+ var bugID string
311+ for streamedBug := range bug.ReadAll(repo) {
312+ if streamedBug.Err != nil {
313+ t.Fatalf("failed to read bug: %v", streamedBug.Err)
314+ }
315+ b := streamedBug.Entity
316+ snap := b.Compile()
317+ if snap.Title == title {
318+ bugID = b.Id().String()
319+ break
320+ }
321+ }
322+ repo.Close()
323+
324+ // Add multiple comments
325+ comments := []string{
326+ "First comment",
327+ "Second comment",
328+ "Third comment with **markdown**",
329+ }
330+
331+ for _, comment := range comments {
332+ if err := runComment(tmpDir, bugID, comment); err != nil {
333+ t.Fatalf("runComment failed: %v", err)
334+ }
335+ }
336+
337+ // Verify all comments were added
338+ repo, err = repository.OpenGoGitRepo(tmpDir, "", nil)
339+ if err != nil {
340+ t.Fatalf("failed to open repo: %v", err)
341+ }
342+ defer repo.Close()
343+
344+ b, err := bug.Read(repo, entity.Id(bugID))
345+ if err != nil {
346+ t.Fatalf("failed to read bug: %v", err)
347+ }
348+
349+ snap := b.Compile()
350+ // Original + 3 comments = 4 total
351+ if len(snap.Comments) != 4 {
352+ t.Errorf("expected 4 comments, got %d", len(snap.Comments))
353+ }
354+
355+ // Verify each comment (index 1-3 are our added comments)
356+ for i, expected := range comments {
357+ if snap.Comments[i+1].Message != expected {
358+ t.Errorf("comment %d message = %q, want %q", i+1, snap.Comments[i+1].Message, expected)
359+ }
360+ }
361+}
+424,
-0
1@@ -0,0 +1,424 @@
2+//go:build integration
3+
4+package main
5+
6+import (
7+ "os/exec"
8+ "strings"
9+ "testing"
10+
11+ "github.com/git-bug/git-bug/entities/bug"
12+ "github.com/git-bug/git-bug/entity"
13+ "github.com/git-bug/git-bug/repository"
14+)
15+
16+// TestEditCommand_EditIssueTitle edits only the issue title
17+func TestEditCommand_EditIssueTitle(t *testing.T) {
18+ tmpDir := t.TempDir()
19+
20+ // Initialize a git repo
21+ initCmd := exec.Command("git", "init")
22+ initCmd.Dir = tmpDir
23+ if err := initCmd.Run(); err != nil {
24+ t.Fatalf("failed to init git repo: %v", err)
25+ }
26+
27+ // Initialize git-bug identities
28+ if err := createIdentity(tmpDir, "Test User", "[email protected]", true); err != nil {
29+ t.Fatalf("failed to create identity: %v", err)
30+ }
31+
32+ // Create a bug
33+ title := "Original Title"
34+ message := "Original description"
35+ if err := runNew(tmpDir, title, message); err != nil {
36+ t.Fatalf("runNew failed: %v", err)
37+ }
38+
39+ // Get the bug ID
40+ repo, err := repository.OpenGoGitRepo(tmpDir, "", nil)
41+ if err != nil {
42+ t.Fatalf("failed to open repo: %v", err)
43+ }
44+
45+ var bugID string
46+ for streamedBug := range bug.ReadAll(repo) {
47+ if streamedBug.Err != nil {
48+ t.Fatalf("failed to read bug: %v", streamedBug.Err)
49+ }
50+ b := streamedBug.Entity
51+ snap := b.Compile()
52+ if snap.Title == title {
53+ bugID = b.Id().String()
54+ break
55+ }
56+ }
57+ repo.Close()
58+
59+ if bugID == "" {
60+ t.Fatal("could not find created bug")
61+ }
62+
63+ // Edit only the title
64+ newTitle := "Updated Title"
65+ if err := runEdit(tmpDir, bugID, newTitle, ""); err != nil {
66+ t.Fatalf("runEdit failed: %v", err)
67+ }
68+
69+ // Verify title was updated
70+ repo, err = repository.OpenGoGitRepo(tmpDir, "", nil)
71+ if err != nil {
72+ t.Fatalf("failed to open repo: %v", err)
73+ }
74+ defer repo.Close()
75+
76+ b, err := bug.Read(repo, entity.Id(bugID))
77+ if err != nil {
78+ t.Fatalf("failed to read bug: %v", err)
79+ }
80+
81+ snap := b.Compile()
82+ if snap.Title != newTitle {
83+ t.Errorf("title = %q, want %q", snap.Title, newTitle)
84+ }
85+ // Description should remain unchanged
86+ if len(snap.Comments) == 0 || snap.Comments[0].Message != message {
87+ t.Errorf("description was changed unexpectedly")
88+ }
89+}
90+
91+// TestEditCommand_EditIssueDescription edits only the issue description
92+func TestEditCommand_EditIssueDescription(t *testing.T) {
93+ tmpDir := t.TempDir()
94+
95+ // Initialize a git repo
96+ initCmd := exec.Command("git", "init")
97+ initCmd.Dir = tmpDir
98+ if err := initCmd.Run(); err != nil {
99+ t.Fatalf("failed to init git repo: %v", err)
100+ }
101+
102+ // Initialize git-bug identities
103+ if err := createIdentity(tmpDir, "Test User", "[email protected]", true); err != nil {
104+ t.Fatalf("failed to create identity: %v", err)
105+ }
106+
107+ // Create a bug
108+ title := "Original Title"
109+ message := "Original description"
110+ if err := runNew(tmpDir, title, message); err != nil {
111+ t.Fatalf("runNew failed: %v", err)
112+ }
113+
114+ // Get the bug ID
115+ repo, err := repository.OpenGoGitRepo(tmpDir, "", nil)
116+ if err != nil {
117+ t.Fatalf("failed to open repo: %v", err)
118+ }
119+
120+ var bugID string
121+ for streamedBug := range bug.ReadAll(repo) {
122+ if streamedBug.Err != nil {
123+ t.Fatalf("failed to read bug: %v", streamedBug.Err)
124+ }
125+ b := streamedBug.Entity
126+ snap := b.Compile()
127+ if snap.Title == title {
128+ bugID = b.Id().String()
129+ break
130+ }
131+ }
132+ repo.Close()
133+
134+ if bugID == "" {
135+ t.Fatal("could not find created bug")
136+ }
137+
138+ // Edit only the description
139+ newMessage := "Updated description"
140+ if err := runEdit(tmpDir, bugID, "", newMessage); err != nil {
141+ t.Fatalf("runEdit failed: %v", err)
142+ }
143+
144+ // Verify description was updated
145+ repo, err = repository.OpenGoGitRepo(tmpDir, "", nil)
146+ if err != nil {
147+ t.Fatalf("failed to open repo: %v", err)
148+ }
149+ defer repo.Close()
150+
151+ b, err := bug.Read(repo, entity.Id(bugID))
152+ if err != nil {
153+ t.Fatalf("failed to read bug: %v", err)
154+ }
155+
156+ snap := b.Compile()
157+ // Title should remain unchanged
158+ if snap.Title != title {
159+ t.Errorf("title was changed unexpectedly")
160+ }
161+ if len(snap.Comments) == 0 || snap.Comments[0].Message != newMessage {
162+ t.Errorf("description = %q, want %q", snap.Comments[0].Message, newMessage)
163+ }
164+}
165+
166+// TestEditCommand_EditIssueBoth edits both title and description
167+func TestEditCommand_EditIssueBoth(t *testing.T) {
168+ tmpDir := t.TempDir()
169+
170+ // Initialize a git repo
171+ initCmd := exec.Command("git", "init")
172+ initCmd.Dir = tmpDir
173+ if err := initCmd.Run(); err != nil {
174+ t.Fatalf("failed to init git repo: %v", err)
175+ }
176+
177+ // Initialize git-bug identities
178+ if err := createIdentity(tmpDir, "Test User", "[email protected]", true); err != nil {
179+ t.Fatalf("failed to create identity: %v", err)
180+ }
181+
182+ // Create a bug
183+ if err := runNew(tmpDir, "Original Title", "Original description"); err != nil {
184+ t.Fatalf("runNew failed: %v", err)
185+ }
186+
187+ // Get the bug ID
188+ repo, err := repository.OpenGoGitRepo(tmpDir, "", nil)
189+ if err != nil {
190+ t.Fatalf("failed to open repo: %v", err)
191+ }
192+
193+ var bugID string
194+ for streamedBug := range bug.ReadAll(repo) {
195+ if streamedBug.Err != nil {
196+ t.Fatalf("failed to read bug: %v", streamedBug.Err)
197+ }
198+ b := streamedBug.Entity
199+ snap := b.Compile()
200+ if snap.Title == "Original Title" {
201+ bugID = b.Id().String()
202+ break
203+ }
204+ }
205+ repo.Close()
206+
207+ // Edit both title and description
208+ newTitle := "Updated Title"
209+ newMessage := "Updated description"
210+ if err := runEdit(tmpDir, bugID, newTitle, newMessage); err != nil {
211+ t.Fatalf("runEdit failed: %v", err)
212+ }
213+
214+ // Verify both were updated
215+ repo, err = repository.OpenGoGitRepo(tmpDir, "", nil)
216+ if err != nil {
217+ t.Fatalf("failed to open repo: %v", err)
218+ }
219+ defer repo.Close()
220+
221+ b, err := bug.Read(repo, entity.Id(bugID))
222+ if err != nil {
223+ t.Fatalf("failed to read bug: %v", err)
224+ }
225+
226+ snap := b.Compile()
227+ if snap.Title != newTitle {
228+ t.Errorf("title = %q, want %q", snap.Title, newTitle)
229+ }
230+ if len(snap.Comments) == 0 || snap.Comments[0].Message != newMessage {
231+ t.Errorf("description = %q, want %q", snap.Comments[0].Message, newMessage)
232+ }
233+}
234+
235+// TestEditCommand_EditComment edits a comment
236+func TestEditCommand_EditComment(t *testing.T) {
237+ tmpDir := t.TempDir()
238+
239+ // Initialize a git repo
240+ initCmd := exec.Command("git", "init")
241+ initCmd.Dir = tmpDir
242+ if err := initCmd.Run(); err != nil {
243+ t.Fatalf("failed to init git repo: %v", err)
244+ }
245+
246+ // Initialize git-bug identities
247+ if err := createIdentity(tmpDir, "Test User", "[email protected]", true); err != nil {
248+ t.Fatalf("failed to create identity: %v", err)
249+ }
250+
251+ // Create a bug
252+ if err := runNew(tmpDir, "Test Bug", "Description"); err != nil {
253+ t.Fatalf("runNew failed: %v", err)
254+ }
255+
256+ // Get the bug ID
257+ repo, err := repository.OpenGoGitRepo(tmpDir, "", nil)
258+ if err != nil {
259+ t.Fatalf("failed to open repo: %v", err)
260+ }
261+
262+ var bugID string
263+ for streamedBug := range bug.ReadAll(repo) {
264+ if streamedBug.Err != nil {
265+ t.Fatalf("failed to read bug: %v", streamedBug.Err)
266+ }
267+ b := streamedBug.Entity
268+ snap := b.Compile()
269+ if snap.Title == "Test Bug" {
270+ bugID = b.Id().String()
271+ break
272+ }
273+ }
274+ repo.Close()
275+
276+ // Add a comment
277+ originalComment := "Original comment text"
278+ if err := runComment(tmpDir, bugID, originalComment); err != nil {
279+ t.Fatalf("runComment failed: %v", err)
280+ }
281+
282+ // Get the comment ID
283+ repo, err = repository.OpenGoGitRepo(tmpDir, "", nil)
284+ if err != nil {
285+ t.Fatalf("failed to open repo: %v", err)
286+ }
287+
288+ b, err := bug.Read(repo, entity.Id(bugID))
289+ if err != nil {
290+ t.Fatalf("failed to read bug: %v", err)
291+ }
292+ snap := b.Compile()
293+ if len(snap.Comments) < 2 {
294+ t.Fatal("expected at least 2 comments")
295+ }
296+ commentID := snap.Comments[1].CombinedId().String()
297+ repo.Close()
298+
299+ // Edit the comment
300+ newComment := "Updated comment text"
301+ if err := runEdit(tmpDir, commentID, "", newComment); err != nil {
302+ t.Fatalf("runEdit failed: %v", err)
303+ }
304+
305+ // Verify comment was updated
306+ repo, err = repository.OpenGoGitRepo(tmpDir, "", nil)
307+ if err != nil {
308+ t.Fatalf("failed to open repo: %v", err)
309+ }
310+ defer repo.Close()
311+
312+ b, err = bug.Read(repo, entity.Id(bugID))
313+ if err != nil {
314+ t.Fatalf("failed to read bug: %v", err)
315+ }
316+
317+ snap = b.Compile()
318+ if len(snap.Comments) < 2 {
319+ t.Fatal("expected at least 2 comments")
320+ }
321+ if snap.Comments[1].Message != newComment {
322+ t.Errorf("comment = %q, want %q", snap.Comments[1].Message, newComment)
323+ }
324+}
325+
326+// TestEditCommand_InvalidID tests error for invalid ID
327+func TestEditCommand_InvalidID(t *testing.T) {
328+ tmpDir := t.TempDir()
329+
330+ // Initialize a git repo
331+ initCmd := exec.Command("git", "init")
332+ initCmd.Dir = tmpDir
333+ if err := initCmd.Run(); err != nil {
334+ t.Fatalf("failed to init git repo: %v", err)
335+ }
336+
337+ // Initialize git-bug identities
338+ if err := createIdentity(tmpDir, "Test User", "[email protected]", true); err != nil {
339+ t.Fatalf("failed to create identity: %v", err)
340+ }
341+
342+ // Try to edit non-existent ID
343+ err := runEdit(tmpDir, "nonexistent123", "Title", "Message")
344+ if err == nil {
345+ t.Error("expected error for invalid ID, got nil")
346+ }
347+
348+ if !strings.Contains(err.Error(), "failed to resolve ID") {
349+ t.Errorf("expected 'failed to resolve ID' error, got: %v", err)
350+ }
351+}
352+
353+// TestEditCommand_WithShortID tests editing using a short ID
354+func TestEditCommand_WithShortID(t *testing.T) {
355+ tmpDir := t.TempDir()
356+
357+ // Initialize a git repo
358+ initCmd := exec.Command("git", "init")
359+ initCmd.Dir = tmpDir
360+ if err := initCmd.Run(); err != nil {
361+ t.Fatalf("failed to init git repo: %v", err)
362+ }
363+
364+ // Initialize git-bug identities
365+ if err := createIdentity(tmpDir, "Test User", "[email protected]", true); err != nil {
366+ t.Fatalf("failed to create identity: %v", err)
367+ }
368+
369+ // Create a bug
370+ if err := runNew(tmpDir, "Original Title", "Original description"); err != nil {
371+ t.Fatalf("runNew failed: %v", err)
372+ }
373+
374+ // Get the bug ID and short ID
375+ repo, err := repository.OpenGoGitRepo(tmpDir, "", nil)
376+ if err != nil {
377+ t.Fatalf("failed to open repo: %v", err)
378+ }
379+
380+ var bugID, shortID string
381+ for streamedBug := range bug.ReadAll(repo) {
382+ if streamedBug.Err != nil {
383+ t.Fatalf("failed to read bug: %v", streamedBug.Err)
384+ }
385+ b := streamedBug.Entity
386+ snap := b.Compile()
387+ if snap.Title == "Original Title" {
388+ bugID = b.Id().String()
389+ if len(bugID) > 7 {
390+ shortID = bugID[:7]
391+ } else {
392+ shortID = bugID
393+ }
394+ break
395+ }
396+ }
397+ repo.Close()
398+
399+ if shortID == "" {
400+ t.Fatal("could not find created bug")
401+ }
402+
403+ // Edit using short ID
404+ newTitle := "Updated Title"
405+ if err := runEdit(tmpDir, shortID, newTitle, ""); err != nil {
406+ t.Fatalf("runEdit with short ID failed: %v", err)
407+ }
408+
409+ // Verify title was updated
410+ repo, err = repository.OpenGoGitRepo(tmpDir, "", nil)
411+ if err != nil {
412+ t.Fatalf("failed to open repo: %v", err)
413+ }
414+ defer repo.Close()
415+
416+ b, err := bug.Read(repo, entity.Id(bugID))
417+ if err != nil {
418+ t.Fatalf("failed to read bug: %v", err)
419+ }
420+
421+ snap := b.Compile()
422+ if snap.Title != newTitle {
423+ t.Errorf("title = %q, want %q", snap.Title, newTitle)
424+ }
425+}
+150,
-0
1@@ -0,0 +1,150 @@
2+package main
3+
4+import (
5+ "testing"
6+)
7+
8+func TestParseEditContent_IssueFormat(t *testing.T) {
9+ tests := []struct {
10+ name string
11+ content string
12+ wantTitle string
13+ wantMessage string
14+ wantErr bool
15+ }{
16+ {
17+ name: "simple title and message",
18+ content: "Title\n\nMessage body",
19+ wantTitle: "Title",
20+ wantMessage: "\nMessage body",
21+ wantErr: false,
22+ },
23+ {
24+ name: "title with comment lines",
25+ content: ";; comment\nTitle\n\nMessage",
26+ wantTitle: "Title",
27+ wantMessage: "Message",
28+ wantErr: false,
29+ },
30+ {
31+ name: "multiline message",
32+ content: "Title\n\nLine 1\nLine 2\nLine 3",
33+ wantTitle: "Title",
34+ wantMessage: "\nLine 1\nLine 2\nLine 3",
35+ wantErr: false,
36+ },
37+ {
38+ name: "title only no message",
39+ content: "Title",
40+ wantTitle: "Title",
41+ wantMessage: "",
42+ wantErr: false,
43+ },
44+ {
45+ name: "empty content",
46+ content: "",
47+ wantTitle: "",
48+ wantMessage: "",
49+ wantErr: true,
50+ },
51+ {
52+ name: "only comment lines",
53+ content: ";; comment 1\n;; comment 2",
54+ wantTitle: "",
55+ wantMessage: "",
56+ wantErr: true,
57+ },
58+ {
59+ name: "title with leading/trailing whitespace in lines",
60+ content: " Title \n\n Line 1 \n Line 2 ",
61+ wantTitle: "Title",
62+ wantMessage: "\n Line 1 \n Line 2 ",
63+ wantErr: false,
64+ },
65+ }
66+
67+ for _, tt := range tests {
68+ t.Run(tt.name, func(t *testing.T) {
69+ gotTitle, gotMessage, err := parseEditContent(tt.content, false)
70+ if (err != nil) != tt.wantErr {
71+ t.Errorf("parseEditContent() error = %v, wantErr %v", err, tt.wantErr)
72+ return
73+ }
74+ if gotTitle != tt.wantTitle {
75+ t.Errorf("parseEditContent() title = %q, want %q", gotTitle, tt.wantTitle)
76+ }
77+ if gotMessage != tt.wantMessage {
78+ t.Errorf("parseEditContent() message = %q, want %q", gotMessage, tt.wantMessage)
79+ }
80+ })
81+ }
82+}
83+
84+func TestParseEditContent_CommentFormat(t *testing.T) {
85+ tests := []struct {
86+ name string
87+ content string
88+ wantTitle string
89+ wantMessage string
90+ wantErr bool
91+ }{
92+ {
93+ name: "simple comment",
94+ content: "Comment text",
95+ wantTitle: "",
96+ wantMessage: "Comment text",
97+ wantErr: false,
98+ },
99+ {
100+ name: "multiline comment",
101+ content: "Line 1\nLine 2\nLine 3",
102+ wantTitle: "",
103+ wantMessage: "Line 1\nLine 2\nLine 3",
104+ wantErr: false,
105+ },
106+ {
107+ name: "comment with instruction lines",
108+ content: ";; instruction\nComment text",
109+ wantTitle: "",
110+ wantMessage: "Comment text",
111+ wantErr: false,
112+ },
113+ {
114+ name: "empty content",
115+ content: "",
116+ wantTitle: "",
117+ wantMessage: "",
118+ wantErr: true,
119+ },
120+ {
121+ name: "only whitespace",
122+ content: " \n ",
123+ wantTitle: "",
124+ wantMessage: "",
125+ wantErr: true,
126+ },
127+ {
128+ name: "only comment lines",
129+ content: ";; comment 1\n;; comment 2",
130+ wantTitle: "",
131+ wantMessage: "",
132+ wantErr: true,
133+ },
134+ }
135+
136+ for _, tt := range tests {
137+ t.Run(tt.name, func(t *testing.T) {
138+ gotTitle, gotMessage, err := parseEditContent(tt.content, true)
139+ if (err != nil) != tt.wantErr {
140+ t.Errorf("parseEditContent() error = %v, wantErr %v", err, tt.wantErr)
141+ return
142+ }
143+ if gotTitle != tt.wantTitle {
144+ t.Errorf("parseEditContent() title = %q, want %q", gotTitle, tt.wantTitle)
145+ }
146+ if gotMessage != tt.wantMessage {
147+ t.Errorf("parseEditContent() message = %q, want %q", gotMessage, tt.wantMessage)
148+ }
149+ })
150+ }
151+}
+158,
-0
1@@ -0,0 +1,158 @@
2+//go:build integration
3+
4+package main
5+
6+import (
7+ "os"
8+ "os/exec"
9+ "path/filepath"
10+ "strings"
11+ "testing"
12+
13+ "github.com/git-bug/git-bug/entities/identity"
14+ "github.com/git-bug/git-bug/repository"
15+)
16+
17+// TestInitCommand_WithJJ tests init when jj config is available
18+func TestInitCommand_WithJJ(t *testing.T) {
19+ tmpDir := t.TempDir()
20+
21+ // Initialize a git repo
22+ initCmd := exec.Command("git", "init")
23+ initCmd.Dir = tmpDir
24+ if err := initCmd.Run(); err != nil {
25+ t.Fatalf("failed to init git repo: %v", err)
26+ }
27+
28+ // Create .jj directory and config file directly
29+ jjDir := filepath.Join(tmpDir, ".jj")
30+ if err := os.MkdirAll(jjDir, 0755); err != nil {
31+ t.Fatalf("failed to create .jj directory: %v", err)
32+ }
33+
34+ // Write jj config file directly
35+ configContent := `[user]
36+name = "JJ Test User"
37+email = "[email protected]"
38+`
39+ configPath := filepath.Join(jjDir, "config.toml")
40+ if err := os.WriteFile(configPath, []byte(configContent), 0644); err != nil {
41+ t.Fatalf("failed to write jj config: %v", err)
42+ }
43+
44+ // Run init
45+ err := runInit(tmpDir)
46+ if err != nil {
47+ t.Fatalf("runInit failed: %v", err)
48+ }
49+
50+ // Verify identities were created
51+ repo, err := repository.OpenGoGitRepo(tmpDir, "", nil)
52+ if err != nil {
53+ t.Fatalf("failed to open repo: %v", err)
54+ }
55+ defer repo.Close()
56+
57+ ids, err := identity.ListLocalIds(repo)
58+ if err != nil {
59+ t.Fatalf("ListLocalIds failed: %v", err)
60+ }
61+
62+ // Should have at least 2 identities (user + agent)
63+ if len(ids) < 2 {
64+ t.Errorf("expected at least 2 identities, got %d", len(ids))
65+ }
66+
67+ // Check if user identity is set
68+ isSet, err := identity.IsUserIdentitySet(repo)
69+ if err != nil {
70+ t.Fatalf("IsUserIdentitySet failed: %v", err)
71+ }
72+ if !isSet {
73+ t.Error("expected user identity to be set")
74+ }
75+}
76+
77+// TestInitCommand_WithoutJJ tests init without jj config (interactive mode)
78+func TestInitCommand_WithoutJJ(t *testing.T) {
79+ tmpDir := t.TempDir()
80+
81+ // Initialize a git repo (no jj)
82+ initCmd := exec.Command("git", "init")
83+ initCmd.Dir = tmpDir
84+ if err := initCmd.Run(); err != nil {
85+ t.Fatalf("failed to init git repo: %v", err)
86+ }
87+
88+ // Simulate user input for interactive mode
89+ input := "Interactive User\[email protected]\n"
90+
91+ // Run init with simulated input
92+ err := runInitWithReader(tmpDir, strings.NewReader(input))
93+ if err != nil {
94+ t.Fatalf("runInitWithReader failed: %v", err)
95+ }
96+
97+ // Verify identities were created
98+ repo, err := repository.OpenGoGitRepo(tmpDir, "", nil)
99+ if err != nil {
100+ t.Fatalf("failed to open repo: %v", err)
101+ }
102+ defer repo.Close()
103+
104+ ids, err := identity.ListLocalIds(repo)
105+ if err != nil {
106+ t.Fatalf("ListLocalIds failed: %v", err)
107+ }
108+
109+ // Should have at least 2 identities (user + agent)
110+ if len(ids) < 2 {
111+ t.Errorf("expected at least 2 identities, got %d", len(ids))
112+ }
113+
114+ // Verify the user identity has the correct info
115+ isSet, err := identity.IsUserIdentitySet(repo)
116+ if err != nil {
117+ t.Fatalf("IsUserIdentitySet failed: %v", err)
118+ }
119+ if !isSet {
120+ t.Error("expected user identity to be set")
121+ }
122+
123+ userIdentity, err := identity.GetUserIdentity(repo)
124+ if err != nil {
125+ t.Fatalf("GetUserIdentity failed: %v", err)
126+ }
127+
128+ if userIdentity.Name() != "Interactive User" {
129+ t.Errorf("expected name 'Interactive User', got %q", userIdentity.Name())
130+ }
131+
132+ if userIdentity.Email() != "[email protected]" {
133+ t.Errorf("expected email '[email protected]', got %q", userIdentity.Email())
134+ }
135+}
136+
137+// TestInitCommand_EmptyNameError tests that empty name is rejected
138+func TestInitCommand_EmptyNameError(t *testing.T) {
139+ tmpDir := t.TempDir()
140+
141+ // Initialize a git repo
142+ initCmd := exec.Command("git", "init")
143+ initCmd.Dir = tmpDir
144+ if err := initCmd.Run(); err != nil {
145+ t.Fatalf("failed to init git repo: %v", err)
146+ }
147+
148+ // Simulate empty name input
149+ input := "\n"
150+
151+ err := runInitWithReader(tmpDir, strings.NewReader(input))
152+ if err == nil {
153+ t.Error("expected error for empty name, got nil")
154+ }
155+
156+ if !strings.Contains(err.Error(), "name cannot be empty") {
157+ t.Errorf("expected 'name cannot be empty' error, got: %v", err)
158+ }
159+}
+1318,
-3
1@@ -1,8 +1,11 @@
2 package main
3
4 import (
5+ "bufio"
6 "fmt"
7+ "io"
8 "os"
9+ "os/exec"
10 "path/filepath"
11 "sort"
12 "strings"
13@@ -11,6 +14,8 @@ import (
14 "github.com/charmbracelet/lipgloss"
15 "github.com/dustin/go-humanize"
16 "github.com/git-bug/git-bug/entities/bug"
17+ "github.com/git-bug/git-bug/entities/identity"
18+ "github.com/git-bug/git-bug/entity"
19 "github.com/git-bug/git-bug/repository"
20 "github.com/picosh/pgit"
21 "github.com/spf13/cobra"
22@@ -384,10 +389,646 @@ func padRight(s string, width int) string {
23 return s + strings.Repeat(" ", padding)
24 }
25
26+// promptUser prompts the user for input with the given message
27+// It reads from stdin and returns the trimmed input
28+func promptUser(prompt string) (string, error) {
29+ fmt.Print(prompt)
30+ scanner := bufio.NewScanner(os.Stdin)
31+ if scanner.Scan() {
32+ return strings.TrimSpace(scanner.Text()), nil
33+ }
34+ if err := scanner.Err(); err != nil {
35+ return "", fmt.Errorf("failed to read input: %w", err)
36+ }
37+ return "", nil
38+}
39+
40+// promptUserWithScanner prompts the user using the provided scanner
41+// It prints the prompt and returns the trimmed input
42+func promptUserWithScanner(prompt string, scanner *bufio.Scanner) (string, error) {
43+ fmt.Print(prompt)
44+ if scanner.Scan() {
45+ return strings.TrimSpace(scanner.Text()), nil
46+ }
47+ if err := scanner.Err(); err != nil {
48+ return "", fmt.Errorf("failed to read input: %w", err)
49+ }
50+ return "", nil
51+}
52+
53+// getEditor returns the editor command to use for interactive editing
54+// Checks $EDITOR environment variable, falls back to 'vi' or 'notepad'
55+func getEditor() string {
56+ if editor := os.Getenv("EDITOR"); editor != "" {
57+ return editor
58+ }
59+ // Default editors based on OS
60+ if os.PathSeparator == '\\' {
61+ return "notepad"
62+ }
63+ return "vi"
64+}
65+
66+// launchEditorWithTemplate opens the user's preferred editor with a custom template
67+// Returns the content written by the user
68+func launchEditorWithTemplate(template string) (string, error) {
69+ // Create temporary file
70+ tmpFile, err := os.CreateTemp("", "bug-edit-*.txt")
71+ if err != nil {
72+ return "", fmt.Errorf("failed to create temp file: %w", err)
73+ }
74+ tmpPath := tmpFile.Name()
75+ defer os.Remove(tmpPath)
76+
77+ if _, err := tmpFile.WriteString(template); err != nil {
78+ tmpFile.Close()
79+ return "", fmt.Errorf("failed to write template: %w", err)
80+ }
81+ tmpFile.Close()
82+
83+ // Launch editor
84+ editor := getEditor()
85+ cmd := exec.Command(editor, tmpPath)
86+ cmd.Stdin = os.Stdin
87+ cmd.Stdout = os.Stdout
88+ cmd.Stderr = os.Stderr
89+
90+ if err := cmd.Run(); err != nil {
91+ return "", fmt.Errorf("editor failed: %w", err)
92+ }
93+
94+ // Read the result
95+ content, err := os.ReadFile(tmpPath)
96+ if err != nil {
97+ return "", fmt.Errorf("failed to read edited file: %w", err)
98+ }
99+
100+ return string(content), nil
101+}
102+
103+// launchEditor opens the user's preferred editor with a temporary file for new issues
104+// Returns the content written by the user
105+func launchEditor(initialContent string) (string, error) {
106+ // Write initial content with instructions
107+ template := `;; Enter your issue title on the first line (required)
108+;; Enter the description below the title
109+;; Lines starting with ;; will be ignored
110+;; Save and close the editor when done
111+;;
112+` + initialContent
113+
114+ return launchEditorWithTemplate(template)
115+}
116+
117+// parseEditorContent parses editor output into title and message
118+// First non-empty line is title, rest is message
119+// Lines starting with ;; are treated as comments and ignored
120+func parseEditorContent(content string) (title, message string, err error) {
121+ lines := strings.Split(content, "\n")
122+ var nonCommentLines []string
123+ commentsFiltered := false
124+
125+ // Filter out comment lines and collect non-empty lines
126+ for _, line := range lines {
127+ trimmed := strings.TrimSpace(line)
128+ if strings.HasPrefix(trimmed, ";;") {
129+ commentsFiltered = true
130+ continue
131+ }
132+ nonCommentLines = append(nonCommentLines, line)
133+ }
134+
135+ // Find first non-empty line for title
136+ titleIdx := -1
137+ for i, line := range nonCommentLines {
138+ if strings.TrimSpace(line) != "" {
139+ title = strings.TrimSpace(line)
140+ titleIdx = i
141+ break
142+ }
143+ }
144+
145+ if titleIdx == -1 {
146+ return "", "", fmt.Errorf("no title provided")
147+ }
148+
149+ // Rest is the message (preserve original formatting)
150+ if titleIdx+1 < len(nonCommentLines) {
151+ messageLines := nonCommentLines[titleIdx+1:]
152+ startIdx := 0
153+ // Trim leading empty lines only if comments were filtered
154+ if commentsFiltered {
155+ for startIdx < len(messageLines) && strings.TrimSpace(messageLines[startIdx]) == "" {
156+ startIdx++
157+ }
158+ }
159+ // Trim trailing empty lines from message
160+ endIdx := len(messageLines)
161+ for endIdx > startIdx && strings.TrimSpace(messageLines[endIdx-1]) == "" {
162+ endIdx--
163+ }
164+ message = strings.Join(messageLines[startIdx:endIdx], "\n")
165+ }
166+
167+ return title, message, nil
168+}
169+
170+// parseEditContent parses editor output for edit operations
171+// It handles two formats:
172+// 1. Issue format: title on first line, blank line, then description
173+// 2. Comment format: single message (when parsing comment content)
174+// For issue format, returns title and message
175+// For comment format, set isComment=true to skip title parsing
176+func parseEditContent(content string, isComment bool) (title, message string, err error) {
177+ lines := strings.Split(content, "\n")
178+ var nonCommentLines []string
179+ commentsFiltered := false
180+
181+ // Filter out comment lines and collect non-empty lines
182+ for _, line := range lines {
183+ trimmed := strings.TrimSpace(line)
184+ if strings.HasPrefix(trimmed, ";;") {
185+ commentsFiltered = true
186+ continue
187+ }
188+ nonCommentLines = append(nonCommentLines, line)
189+ }
190+
191+ if isComment {
192+ // Comment format: just extract the message
193+ startIdx := 0
194+ for startIdx < len(nonCommentLines) && strings.TrimSpace(nonCommentLines[startIdx]) == "" {
195+ startIdx++
196+ }
197+ if startIdx >= len(nonCommentLines) {
198+ return "", "", fmt.Errorf("no content provided")
199+ }
200+ endIdx := len(nonCommentLines)
201+ for endIdx > startIdx && strings.TrimSpace(nonCommentLines[endIdx-1]) == "" {
202+ endIdx--
203+ }
204+ message = strings.Join(nonCommentLines[startIdx:endIdx], "\n")
205+ return "", message, nil
206+ }
207+
208+ // Issue format: title on first line, description follows
209+ // Find first non-empty line for title
210+ titleIdx := -1
211+ for i, line := range nonCommentLines {
212+ if strings.TrimSpace(line) != "" {
213+ title = strings.TrimSpace(line)
214+ titleIdx = i
215+ break
216+ }
217+ }
218+
219+ if titleIdx == -1 {
220+ return "", "", fmt.Errorf("no title provided")
221+ }
222+
223+ // Rest is the message (preserve original formatting)
224+ if titleIdx+1 < len(nonCommentLines) {
225+ messageLines := nonCommentLines[titleIdx+1:]
226+ startIdx := 0
227+ // Trim leading empty lines only if comments were filtered
228+ if commentsFiltered {
229+ for startIdx < len(messageLines) && strings.TrimSpace(messageLines[startIdx]) == "" {
230+ startIdx++
231+ }
232+ }
233+ // Trim trailing empty lines from message
234+ endIdx := len(messageLines)
235+ for endIdx > startIdx && strings.TrimSpace(messageLines[endIdx-1]) == "" {
236+ endIdx--
237+ }
238+ message = strings.Join(messageLines[startIdx:endIdx], "\n")
239+ }
240+
241+ return title, message, nil
242+}
243+
244+// detectJJConfig attempts to get user.name and user.email from jj config
245+// Returns empty strings if .jj directory doesn't exist or config is not found
246+// Note: We read the config file directly because 'jj config get' walks up the
247+// directory tree and may find parent repo configs, which is not what we want.
248+func detectJJConfig(repoPath string) (name, email string, err error) {
249+ configPath := filepath.Join(repoPath, ".jj", "config.toml")
250+
251+ // Check if the config file exists
252+ if _, err := os.Stat(configPath); os.IsNotExist(err) {
253+ return "", "", nil
254+ }
255+
256+ // Read the config file
257+ content, err := os.ReadFile(configPath)
258+ if err != nil {
259+ return "", "", nil
260+ }
261+
262+ // Parse the TOML content line by line to extract user.name and user.email
263+ // We do simple parsing to avoid adding a TOML dependency
264+ lines := strings.Split(string(content), "\n")
265+ inUserSection := false
266+
267+ for _, line := range lines {
268+ trimmed := strings.TrimSpace(line)
269+
270+ // Check if we're entering the [user] section
271+ if trimmed == "[user]" {
272+ inUserSection = true
273+ continue
274+ }
275+
276+ // Check if we're leaving the [user] section (new section starts)
277+ if strings.HasPrefix(trimmed, "[") && trimmed != "[user]" {
278+ inUserSection = false
279+ continue
280+ }
281+
282+ // Extract name and email from the user section
283+ if inUserSection {
284+ if strings.HasPrefix(trimmed, "name") {
285+ parts := strings.SplitN(trimmed, "=", 2)
286+ if len(parts) == 2 {
287+ name = strings.Trim(strings.TrimSpace(parts[1]), `"`)
288+ }
289+ }
290+ if strings.HasPrefix(trimmed, "email") {
291+ parts := strings.SplitN(trimmed, "=", 2)
292+ if len(parts) == 2 {
293+ email = strings.Trim(strings.TrimSpace(parts[1]), `"`)
294+ }
295+ }
296+ }
297+ }
298+
299+ return name, email, nil
300+}
301+
302+// createIdentity creates a new git-bug identity with the given name and email
303+// If setAsUser is true, the identity will be set as the current user identity
304+func createIdentity(repoPath, name, email string, setAsUser bool) error {
305+ repo, err := repository.OpenGoGitRepo(repoPath, "", nil)
306+ if err != nil {
307+ return fmt.Errorf("failed to open repository: %w", err)
308+ }
309+ defer repo.Close()
310+
311+ newIdentity, err := identity.NewIdentity(repo, name, email)
312+ if err != nil {
313+ return fmt.Errorf("failed to create identity: %w", err)
314+ }
315+
316+ if err := newIdentity.Commit(repo); err != nil {
317+ return fmt.Errorf("failed to commit identity: %w", err)
318+ }
319+
320+ if setAsUser {
321+ if err := identity.SetUserIdentity(repo, newIdentity); err != nil {
322+ return fmt.Errorf("failed to set user identity: %w", err)
323+ }
324+ }
325+
326+ return nil
327+}
328+
329+// getAgentIdentity retrieves the agent identity from the repository
330+// The agent identity is created during 'bug init' with name "agent" and empty email
331+func getAgentIdentity(repo repository.Repo) (*identity.Identity, error) {
332+ // List all local identities
333+ ids, err := identity.ListLocalIds(repo)
334+ if err != nil {
335+ return nil, fmt.Errorf("failed to list identities: %w", err)
336+ }
337+
338+ // Find the identity with name "agent"
339+ for _, id := range ids {
340+ i, err := identity.ReadLocal(repo, id)
341+ if err != nil {
342+ continue // Skip identities we can't read
343+ }
344+ if i.Name() == "agent" {
345+ return i, nil
346+ }
347+ }
348+
349+ return nil, fmt.Errorf("agent identity not found. Run 'bug init' first")
350+}
351+
352+// resolveBugID resolves a bug ID (short or full) to a bug entity
353+// It uses ShortIDMap to handle short ID resolution
354+func resolveBugID(repoPath, idStr string) (*bug.Bug, error) {
355+ repo, err := repository.OpenGoGitRepo(repoPath, "", nil)
356+ if err != nil {
357+ return nil, fmt.Errorf("failed to open repository: %w", err)
358+ }
359+ defer repo.Close()
360+
361+ // Generate short ID map for resolution
362+ gen := pgit.NewShortIDGenerator(repoPath)
363+ shortIDMap, err := gen.Generate()
364+ if err != nil {
365+ return nil, fmt.Errorf("failed to generate short IDs: %w", err)
366+ }
367+
368+ // Try to resolve the ID (could be short or full)
369+ fullID, err := shortIDMap.GetFullID(idStr)
370+ if err != nil {
371+ return nil, fmt.Errorf("failed to resolve bug ID %q: %w", idStr, err)
372+ }
373+
374+ // Read the bug using the full ID
375+ b, err := bug.Read(repo, entity.Id(fullID))
376+ if err != nil {
377+ return nil, fmt.Errorf("failed to read bug %q: %w", idStr, err)
378+ }
379+
380+ return b, nil
381+}
382+
383+// resolveCommentID resolves a comment ID (short or full) and returns the bug and comment
384+// It uses ShortIDMap to handle short ID resolution
385+func resolveCommentID(repoPath, idStr string) (*bug.Bug, *bug.Comment, error) {
386+ repo, err := repository.OpenGoGitRepo(repoPath, "", nil)
387+ if err != nil {
388+ return nil, nil, fmt.Errorf("failed to open repository: %w", err)
389+ }
390+ defer repo.Close()
391+
392+ // Generate short ID map for resolution
393+ gen := pgit.NewShortIDGenerator(repoPath)
394+ shortIDMap, err := gen.Generate()
395+ if err != nil {
396+ return nil, nil, fmt.Errorf("failed to generate short IDs: %w", err)
397+ }
398+
399+ // Try to resolve the ID (could be short or full)
400+ fullID, err := shortIDMap.GetFullID(idStr)
401+ if err != nil {
402+ return nil, nil, fmt.Errorf("failed to resolve comment ID %q: %w", idStr, err)
403+ }
404+
405+ // Parse the combined ID to get bug ID prefix and comment ID prefix
406+ combinedID := entity.CombinedId(fullID)
407+ bugIDPrefix, _ := entity.SeparateIds(string(combinedID))
408+
409+ // SeparateIds returns prefixes, not full IDs. We need to look up the full bug ID.
410+ // The bug ID prefix is 50 characters, but we need the full 64-character ID.
411+ fullBugID, err := shortIDMap.GetFullID(bugIDPrefix)
412+ if err != nil {
413+ return nil, nil, fmt.Errorf("failed to resolve bug ID from prefix %q: %w", bugIDPrefix, err)
414+ }
415+
416+ // Read the bug using the full bug ID
417+ b, err := bug.Read(repo, entity.Id(fullBugID))
418+ if err != nil {
419+ return nil, nil, fmt.Errorf("failed to read bug for comment %q: %w", idStr, err)
420+ }
421+
422+ // Search for the comment in the bug
423+ snap := b.Compile()
424+ comment, err := snap.SearchComment(combinedID)
425+ if err != nil {
426+ return nil, nil, fmt.Errorf("comment %q not found in bug: %w", idStr, err)
427+ }
428+
429+ return b, comment, nil
430+}
431+
432+// IDType represents the type of ID resolved
433+type IDType int
434+
435+const (
436+ IDTypeBug IDType = iota
437+ IDTypeComment
438+)
439+
440+// ResolvedID holds the result of ID resolution
441+type ResolvedID struct {
442+ Type IDType
443+ Bug *bug.Bug
444+ Comment *bug.Comment
445+ ID string // Full ID
446+}
447+
448+// resolveID attempts to resolve an ID as a bug ID first, then as a comment ID
449+// Returns ResolvedID with the type and appropriate data
450+func resolveID(repoPath, idStr string) (*ResolvedID, error) {
451+ // Try to resolve as bug ID first
452+ b, err := resolveBugID(repoPath, idStr)
453+ if err == nil {
454+ return &ResolvedID{
455+ Type: IDTypeBug,
456+ Bug: b,
457+ ID: b.Id().String(),
458+ }, nil
459+ }
460+
461+ // Try to resolve as comment ID
462+ b, comment, err := resolveCommentID(repoPath, idStr)
463+ if err == nil {
464+ return &ResolvedID{
465+ Type: IDTypeComment,
466+ Bug: b,
467+ Comment: comment,
468+ ID: comment.CombinedId().String(),
469+ }, nil
470+ }
471+
472+ // Neither resolved - return combined error
473+ return nil, fmt.Errorf("failed to resolve ID %q as bug or comment: %w", idStr, err)
474+}
475+
476+// launchCommentEditor opens the user's preferred editor for comment input
477+// Returns the content written by the user with comment lines filtered
478+func launchCommentEditor(initialContent string) (string, error) {
479+ // Write initial content with instructions
480+ template := `;; Enter your comment below
481+;; Lines starting with ;; will be ignored
482+;; You can use markdown formatting
483+;; Save and close the editor when done
484+;;
485+` + initialContent
486+
487+ return launchEditorWithTemplate(template)
488+}
489+
490+// parseCommentContent parses editor output for comments
491+// Lines starting with ;; are treated as comments and ignored
492+func parseCommentContent(content string) (string, error) {
493+ lines := strings.Split(content, "\n")
494+ var nonCommentLines []string
495+
496+ // Filter out comment lines
497+ for _, line := range lines {
498+ trimmed := strings.TrimSpace(line)
499+ if strings.HasPrefix(trimmed, ";;") {
500+ continue
501+ }
502+ nonCommentLines = append(nonCommentLines, line)
503+ }
504+
505+ // Find first non-empty line
506+ startIdx := 0
507+ for startIdx < len(nonCommentLines) && strings.TrimSpace(nonCommentLines[startIdx]) == "" {
508+ startIdx++
509+ }
510+
511+ // If no content left, return error
512+ if startIdx >= len(nonCommentLines) {
513+ return "", fmt.Errorf("no comment provided")
514+ }
515+
516+ // Trim trailing empty lines
517+ endIdx := len(nonCommentLines)
518+ for endIdx > startIdx && strings.TrimSpace(nonCommentLines[endIdx-1]) == "" {
519+ endIdx--
520+ }
521+
522+ // Join remaining lines
523+ message := strings.Join(nonCommentLines[startIdx:endIdx], "\n")
524+
525+ return message, nil
526+}
527+
528+// runNew creates a new bug/issue in the repository
529+// If title is empty, opens editor for interactive input
530+func runNew(repoPath, title, message string) error {
531+ // If no title provided via flags, open editor
532+ if title == "" && message == "" {
533+ content, err := launchEditor("")
534+ if err != nil {
535+ return err
536+ }
537+ title, message, err = parseEditorContent(content)
538+ if err != nil {
539+ return err
540+ }
541+ } else if title == "" {
542+ // Message provided but no title - still need editor
543+ content, err := launchEditor(message)
544+ if err != nil {
545+ return err
546+ }
547+ title, message, err = parseEditorContent(content)
548+ if err != nil {
549+ return err
550+ }
551+ }
552+ // If only title provided, message can be empty (that's OK)
553+
554+ // Validate title
555+ if strings.TrimSpace(title) == "" {
556+ return fmt.Errorf("title cannot be empty")
557+ }
558+
559+ // Open repository
560+ repo, err := repository.OpenGoGitRepo(repoPath, "", nil)
561+ if err != nil {
562+ return fmt.Errorf("failed to open repository: %w", err)
563+ }
564+ defer repo.Close()
565+
566+ // Get user identity
567+ author, err := identity.GetUserIdentity(repo)
568+ if err != nil {
569+ return fmt.Errorf("failed to get user identity: %w\n\nRun 'bug init' first to set up identities", err)
570+ }
571+
572+ // Create the bug
573+ unixTime := time.Now().Unix()
574+ newBug, _, err := bug.Create(author, unixTime, title, message, nil, nil)
575+ if err != nil {
576+ return fmt.Errorf("failed to create bug: %w", err)
577+ }
578+
579+ // Commit the bug to the repository
580+ if err := newBug.Commit(repo); err != nil {
581+ return fmt.Errorf("failed to commit bug: %w", err)
582+ }
583+
584+ // Get the bug ID and display first 7 characters (like bug ls table)
585+ bugID := newBug.Id().String()
586+ if len(bugID) > 7 {
587+ bugID = bugID[:7]
588+ }
589+
590+ fmt.Printf("Created bug %s: %s\n", bugID, title)
591+ return nil
592+}
593+
594+// runInit creates initial identities in the repository
595+// It attempts to get user info from jj config if .jj exists, otherwise prompts the user
596+// It also creates an agent identity
597+func runInit(repoPath string) error {
598+ return runInitWithReader(repoPath, os.Stdin)
599+}
600+
601+// runInitWithReader is the testable version that accepts an io.Reader for stdin
602+func runInitWithReader(repoPath string, reader io.Reader) error {
603+ // Try to get user info from jj config
604+ name, email, err := detectJJConfig(repoPath)
605+ if err != nil {
606+ return fmt.Errorf("failed to detect jj config: %w", err)
607+ }
608+
609+ // If jj config was found, use it
610+ if name != "" && email != "" {
611+ fmt.Printf("Creating user identity from jj config: %s <%s>\n", name, email)
612+ } else {
613+ // Prompt user for name and email
614+ fmt.Println("No jj config found. Please provide your information:")
615+
616+ // Create a single scanner to use for all reads
617+ scanner := bufio.NewScanner(reader)
618+
619+ name, err = promptUserWithScanner("Name: ", scanner)
620+ if err != nil {
621+ return fmt.Errorf("failed to read name: %w", err)
622+ }
623+ if name == "" {
624+ return fmt.Errorf("name cannot be empty")
625+ }
626+
627+ email, err = promptUserWithScanner("Email: ", scanner)
628+ if err != nil {
629+ return fmt.Errorf("failed to read email: %w", err)
630+ }
631+ }
632+
633+ // Create user identity
634+ fmt.Printf("Creating user identity: %s <%s>\n", name, email)
635+ if err := createIdentity(repoPath, name, email, true); err != nil {
636+ return fmt.Errorf("failed to create user identity: %w", err)
637+ }
638+ fmt.Println("User identity created successfully")
639+
640+ // Create agent identity
641+ // Empty email is intentional per spec: equivalent to `git-bug user new --name 'agent' --email ''`
642+ fmt.Println("Creating agent identity...")
643+ if err := createIdentity(repoPath, "agent", "", false); err != nil {
644+ return fmt.Errorf("failed to create agent identity: %w", err)
645+ }
646+ fmt.Println("Agent identity created successfully")
647+
648+ return nil
649+}
650+
651 var (
652- repoPath string
653- filterFlag string
654- sortFlag string
655+ repoPath string
656+ filterFlag string
657+ sortFlag string
658+ newTitle string
659+ newMessage string
660+ agentTitle string
661+ agentMessage string
662+ commentMessage string
663+ commentBugID string
664+ agentCommentMsg string
665+ editTitle string
666+ editMessage string
667+ agentEditTitle string
668+ agentEditMsg string
669 )
670
671 var rootCmd = &cobra.Command{
672@@ -447,11 +1088,685 @@ Sorting:
673 },
674 }
675
676+var initCmd = &cobra.Command{
677+ Use: "init",
678+ Short: "Initialize git-bug identities in the repository",
679+ Long: `Initialize git-bug by creating initial identities in the repository.
680+
681+If a .jj directory exists, the command will attempt to read user.name and user.email
682+from jj config and create a user identity. Otherwise, it will prompt you interactively
683+for your name and email. It also creates an agent identity.
684+
685+Examples:
686+ bug init # Initialize in current directory
687+ bug init --repo /path/to/repo # Initialize in specific repository`,
688+ RunE: func(cmd *cobra.Command, args []string) error {
689+ return runInit(repoPath)
690+ },
691+}
692+
693+var newCmd = &cobra.Command{
694+ Use: "new",
695+ Short: "Create a new issue/bug",
696+ Long: `Create a new issue in the git-bug repository.
697+
698+You can provide the title and message via flags:
699+ bug new --title "Bug title" --message "Detailed description"
700+ bug new -t "Bug title" -m "Detailed description"
701+
702+Or launch an editor to write them interactively:
703+ bug new
704+
705+The editor will open with a template. The first non-empty line becomes the title,
706+and the rest becomes the description.`,
707+ RunE: func(cmd *cobra.Command, args []string) error {
708+ return runNew(repoPath, newTitle, newMessage)
709+ },
710+}
711+
712+var agentCmd = &cobra.Command{
713+ Use: "agent",
714+ Short: "Agent commands for automated bug creation",
715+ Long: `Commands for creating bugs as the agent identity.
716+
717+These commands are designed for automated/agent use and are non-interactive.
718+All agent commands use the agent identity created during 'bug init'.
719+
720+Example:
721+ bug agent new --title "Automated bug" --message "Found by CI"`,
722+}
723+
724+var agentNewCmd = &cobra.Command{
725+ Use: "new",
726+ Short: "Create a new issue as the agent",
727+ Long: `Create a new issue in the git-bug repository using the agent identity.
728+
729+This command is non-interactive and requires both --title and --message flags.
730+It uses the agent identity created during 'bug init'.
731+
732+Example:
733+ bug agent new --title "CI Failure" --message "Build failed on commit abc123"
734+ bug agent new -t "Bug found" -m "Automated scan detected issue"`,
735+ RunE: func(cmd *cobra.Command, args []string) error {
736+ return runAgentNew(repoPath, agentTitle, agentMessage)
737+ },
738+}
739+
740+var commentCmd = &cobra.Command{
741+ Use: "comment [bugid]",
742+ Short: "Add a comment to an existing bug",
743+ Long: `Add a comment to an existing bug in the git-bug repository.
744+
745+You can provide the comment via the message flag:
746+ bug comment abc1234 --message "This is my comment"
747+ bug comment abc1234 -m "This is my comment"
748+
749+Or launch an editor to write it interactively:
750+ bug comment abc1234
751+
752+The editor will open with a template. Lines starting with ;; are ignored.
753+You can use markdown formatting in your comment.`,
754+ Args: cobra.ExactArgs(1),
755+ RunE: func(cmd *cobra.Command, args []string) error {
756+ commentBugID = args[0]
757+ return runComment(repoPath, commentBugID, commentMessage)
758+ },
759+}
760+
761+var editCmd = &cobra.Command{
762+ Use: "edit [bugid|commentid]",
763+ Short: "Edit an issue or comment",
764+ Long: `Edit an existing issue or comment in the git-bug repository.
765+
766+The ID can be a bug ID (full or short) or a comment ID (full or short).
767+The command first tries to resolve the ID as a bug, then as a comment.
768+
769+For issues:
770+ bug edit abc1234 # Open editor with current title and description
771+ bug edit abc1234 -t "New Title" # Update only the title
772+ bug edit abc1234 -m "New desc" # Update only the description
773+ bug edit abc1234 -t "Title" -m "Desc" # Update both
774+
775+For comments:
776+ bug edit def5678 # Open editor with current comment text
777+ bug edit def5678 -m "New text" # Update the comment
778+
779+When using the editor:
780+ - For issues: title on first line, blank line, then description
781+ - For comments: edit the comment text directly
782+ - Lines starting with ;; are ignored (instructions)`,
783+ Args: cobra.ExactArgs(1),
784+ RunE: func(cmd *cobra.Command, args []string) error {
785+ return runEdit(repoPath, args[0], editTitle, editMessage)
786+ },
787+}
788+
789+var agentCommentCmd = &cobra.Command{
790+ Use: "comment [bugid]",
791+ Short: "Add a comment to a bug as the agent",
792+ Long: `Add a comment to an existing bug using the agent identity.
793+
794+This command is non-interactive and requires the --message flag.
795+It uses the agent identity created during 'bug init'.
796+
797+Example:
798+ bug agent comment abc1234 --message "Automated analysis complete"
799+ bug agent comment abc1234 -m "Build passed all tests"`,
800+ Args: cobra.ExactArgs(1),
801+ RunE: func(cmd *cobra.Command, args []string) error {
802+ commentBugID = args[0]
803+ return runAgentComment(repoPath, commentBugID, agentCommentMsg)
804+ },
805+}
806+
807+var agentEditCmd = &cobra.Command{
808+ Use: "edit [bugid|commentid]",
809+ Short: "Edit an issue or comment as the agent",
810+ Long: `Edit an existing issue or comment using the agent identity.
811+
812+This command is non-interactive and requires at least one of --title or --message.
813+It uses the agent identity created during 'bug init'.
814+
815+For issues:
816+ bug agent edit abc1234 --title "New Title"
817+ bug agent edit abc1234 --message "New description"
818+ bug agent edit abc1234 --title "Title" --message "Description"
819+
820+For comments:
821+ bug agent edit def5678 --message "Updated comment text"`,
822+ Args: cobra.ExactArgs(1),
823+ RunE: func(cmd *cobra.Command, args []string) error {
824+ return runAgentEdit(repoPath, args[0], agentEditTitle, agentEditMsg)
825+ },
826+}
827+
828 func init() {
829 rootCmd.PersistentFlags().StringVar(&repoPath, "repo", ".", "path to git repository")
830 listCmd.Flags().StringVarP(&filterFlag, "filter", "f", "", "filter issues (label:value or age:<duration>)")
831 listCmd.Flags().StringVarP(&sortFlag, "sort", "s", "", "sort issues (field:direction, default: age:desc)")
832+
833+ newCmd.Flags().StringVarP(&newTitle, "title", "t", "", "issue title")
834+ newCmd.Flags().StringVarP(&newMessage, "message", "m", "", "issue description/message")
835+
836+ // Agent new command flags - both required, no shorthand to force explicit usage
837+ agentNewCmd.Flags().StringVar(&agentTitle, "title", "", "issue title (required)")
838+ agentNewCmd.Flags().StringVar(&agentMessage, "message", "", "issue description (required)")
839+ agentNewCmd.MarkFlagRequired("title")
840+ agentNewCmd.MarkFlagRequired("message")
841+
842+ // Comment command flags - message is optional (opens editor if not provided)
843+ commentCmd.Flags().StringVarP(&commentMessage, "message", "m", "", "comment message (opens editor if not provided)")
844+
845+ // Agent comment command flags - message is required (non-interactive)
846+ agentCommentCmd.Flags().StringVarP(&agentCommentMsg, "message", "m", "", "comment message (required)")
847+ agentCommentCmd.MarkFlagRequired("message")
848+
849+ // Edit command flags - both optional (opens editor if neither provided)
850+ editCmd.Flags().StringVarP(&editTitle, "title", "t", "", "new issue title")
851+ editCmd.Flags().StringVarP(&editMessage, "message", "m", "", "new issue description or comment text")
852+
853+ // Agent edit command flags - at least one required
854+ agentEditCmd.Flags().StringVar(&agentEditTitle, "title", "", "new issue title")
855+ agentEditCmd.Flags().StringVarP(&agentEditMsg, "message", "m", "", "new issue description or comment text")
856+
857 rootCmd.AddCommand(listCmd)
858+ rootCmd.AddCommand(initCmd)
859+ rootCmd.AddCommand(newCmd)
860+ rootCmd.AddCommand(commentCmd)
861+ rootCmd.AddCommand(editCmd)
862+
863+ // Add agent command with its subcommands
864+ agentCmd.AddCommand(agentNewCmd)
865+ agentCmd.AddCommand(agentCommentCmd)
866+ agentCmd.AddCommand(agentEditCmd)
867+ rootCmd.AddCommand(agentCmd)
868+}
869+
870+// runAgentNew creates a new bug/issue using the agent identity
871+// Both title and message are required - this is non-interactive
872+func runAgentNew(repoPath, title, message string) error {
873+ // Validate inputs (both required for agent commands)
874+ if strings.TrimSpace(title) == "" {
875+ return fmt.Errorf("title cannot be empty")
876+ }
877+ if strings.TrimSpace(message) == "" {
878+ return fmt.Errorf("message cannot be empty")
879+ }
880+
881+ // Open repository
882+ repo, err := repository.OpenGoGitRepo(repoPath, "", nil)
883+ if err != nil {
884+ return fmt.Errorf("failed to open repository: %w", err)
885+ }
886+ defer repo.Close()
887+
888+ // Get agent identity (not user identity)
889+ author, err := getAgentIdentity(repo)
890+ if err != nil {
891+ return err
892+ }
893+
894+ // Create the bug
895+ unixTime := time.Now().Unix()
896+ newBug, _, err := bug.Create(author, unixTime, title, message, nil, nil)
897+ if err != nil {
898+ return fmt.Errorf("failed to create bug: %w", err)
899+ }
900+
901+ // Commit the bug to persist it
902+ if err := newBug.Commit(repo); err != nil {
903+ return fmt.Errorf("failed to commit bug: %w", err)
904+ }
905+
906+ // Get the bug ID and display first 7 characters (like bug ls table)
907+ bugID := newBug.Id().String()
908+ if len(bugID) > 7 {
909+ bugID = bugID[:7]
910+ }
911+
912+ fmt.Printf("Created bug %s: %s\n", bugID, title)
913+ return nil
914+}
915+
916+// runComment adds a comment to an existing bug
917+// If message is empty, opens editor for interactive input
918+func runComment(repoPath, bugIDStr, message string) error {
919+ // If no message provided, open editor
920+ if strings.TrimSpace(message) == "" {
921+ content, err := launchCommentEditor("")
922+ if err != nil {
923+ return err
924+ }
925+ message, err = parseCommentContent(content)
926+ if err != nil {
927+ return err
928+ }
929+ }
930+
931+ // Validate message
932+ if strings.TrimSpace(message) == "" {
933+ return fmt.Errorf("comment cannot be empty")
934+ }
935+
936+ // Resolve bug ID
937+ b, err := resolveBugID(repoPath, bugIDStr)
938+ if err != nil {
939+ return err
940+ }
941+
942+ // Open repository for identity lookup
943+ repo, err := repository.OpenGoGitRepo(repoPath, "", nil)
944+ if err != nil {
945+ return fmt.Errorf("failed to open repository: %w", err)
946+ }
947+ defer repo.Close()
948+
949+ // Get user identity
950+ author, err := identity.GetUserIdentity(repo)
951+ if err != nil {
952+ return fmt.Errorf("failed to get user identity: %w\n\nRun 'bug init' first to set up identities", err)
953+ }
954+
955+ // Add the comment
956+ unixTime := time.Now().Unix()
957+ commentID, _, err := bug.AddComment(b, author, unixTime, message, nil, nil)
958+ if err != nil {
959+ return fmt.Errorf("failed to add comment: %w", err)
960+ }
961+
962+ // Commit the bug to persist the comment
963+ if err = b.Commit(repo); err != nil {
964+ return fmt.Errorf("failed to commit comment: %w", err)
965+ }
966+
967+ // Get IDs for display (first 7 characters)
968+ displayCommentID := commentID.String()
969+ displayBugID := b.Id().String()
970+ if len(displayCommentID) > 7 {
971+ displayCommentID = displayCommentID[:7]
972+ }
973+ if len(displayBugID) > 7 {
974+ displayBugID = displayBugID[:7]
975+ }
976+
977+ // Display success message
978+ fmt.Printf("Created comment %s for bug %s\n", displayCommentID, displayBugID)
979+ return nil
980+}
981+
982+// runAgentComment adds a comment to an existing bug as the agent
983+// Message is required - this is non-interactive
984+func runAgentComment(repoPath, bugIDStr, message string) error {
985+ // Validate message (required for agent commands)
986+ if strings.TrimSpace(message) == "" {
987+ return fmt.Errorf("message cannot be empty")
988+ }
989+
990+ // Resolve bug ID
991+ b, err := resolveBugID(repoPath, bugIDStr)
992+ if err != nil {
993+ return err
994+ }
995+
996+ // Open repository for identity lookup
997+ repo, err := repository.OpenGoGitRepo(repoPath, "", nil)
998+ if err != nil {
999+ return fmt.Errorf("failed to open repository: %w", err)
1000+ }
1001+ defer repo.Close()
1002+
1003+ // Get agent identity
1004+ author, err := getAgentIdentity(repo)
1005+ if err != nil {
1006+ return err
1007+ }
1008+
1009+ // Add the comment
1010+ unixTime := time.Now().Unix()
1011+ commentID, _, err := bug.AddComment(b, author, unixTime, message, nil, nil)
1012+ if err != nil {
1013+ return fmt.Errorf("failed to add comment: %w", err)
1014+ }
1015+
1016+ // Commit the bug to persist the comment
1017+ if err = b.Commit(repo); err != nil {
1018+ return fmt.Errorf("failed to commit comment: %w", err)
1019+ }
1020+
1021+ // Get IDs for display (first 7 characters)
1022+ displayCommentID := commentID.String()
1023+ displayBugID := b.Id().String()
1024+ if len(displayCommentID) > 7 {
1025+ displayCommentID = displayCommentID[:7]
1026+ }
1027+ if len(displayBugID) > 7 {
1028+ displayBugID = displayBugID[:7]
1029+ }
1030+
1031+ // Display success message
1032+ fmt.Printf("Created comment %s for bug %s\n", displayCommentID, displayBugID)
1033+ return nil
1034+}
1035+
1036+// runEdit edits an issue or comment
1037+// If title and message flags are provided, uses them directly
1038+// Otherwise, opens an editor with current content pre-populated
1039+func runEdit(repoPath, idStr, newTitle, newMessage string) error {
1040+ // Resolve the ID (bug or comment)
1041+ resolved, err := resolveID(repoPath, idStr)
1042+ if err != nil {
1043+ return err
1044+ }
1045+
1046+ // Open repository for identity lookup
1047+ repo, err := repository.OpenGoGitRepo(repoPath, "", nil)
1048+ if err != nil {
1049+ return fmt.Errorf("failed to open repository: %w", err)
1050+ }
1051+ defer repo.Close()
1052+
1053+ // Get user identity
1054+ author, err := identity.GetUserIdentity(repo)
1055+ if err != nil {
1056+ return fmt.Errorf("failed to get user identity: %w\n\nRun 'bug init' first to set up identities", err)
1057+ }
1058+
1059+ unixTime := time.Now().Unix()
1060+
1061+ switch resolved.Type {
1062+ case IDTypeBug:
1063+ return editBug(repo, resolved.Bug, author, unixTime, newTitle, newMessage)
1064+ case IDTypeComment:
1065+ return editComment(repo, resolved.Bug, resolved.Comment, author, unixTime, newMessage)
1066+ }
1067+
1068+ return fmt.Errorf("unknown ID type")
1069+}
1070+
1071+// editBug edits an issue's title and/or description
1072+func editBug(repo repository.ClockedRepo, b *bug.Bug, author identity.Interface, unixTime int64, newTitle, newMessage string) error {
1073+ snap := b.Compile()
1074+ currentTitle := snap.Title
1075+ currentMessage := ""
1076+ if len(snap.Comments) > 0 {
1077+ currentMessage = snap.Comments[0].Message
1078+ }
1079+
1080+ // If no flags provided, open editor with current content
1081+ if strings.TrimSpace(newTitle) == "" && strings.TrimSpace(newMessage) == "" {
1082+ // Prepare template: title on first line, blank line, description
1083+ template := fmt.Sprintf("%s\n\n%s", currentTitle, currentMessage)
1084+
1085+ // Add edit instructions
1086+ template = `;; Edit the title on the first line (required)
1087+;; Edit the description below the blank line
1088+;; Lines starting with ;; will be ignored
1089+;; Save and close the editor when done
1090+;;
1091+` + template
1092+
1093+ content, err := launchEditorWithTemplate(template)
1094+ if err != nil {
1095+ return err
1096+ }
1097+ newTitle, newMessage, err = parseEditContent(content, false)
1098+ if err != nil {
1099+ return err
1100+ }
1101+ } else {
1102+ // Flags provided - use current values for fields not specified
1103+ if strings.TrimSpace(newTitle) == "" {
1104+ newTitle = currentTitle
1105+ }
1106+ if strings.TrimSpace(newMessage) == "" {
1107+ newMessage = currentMessage
1108+ }
1109+ }
1110+
1111+ // Validate title
1112+ if strings.TrimSpace(newTitle) == "" {
1113+ return fmt.Errorf("title cannot be empty")
1114+ }
1115+
1116+ // Apply changes
1117+ changes := false
1118+
1119+ // Update title if changed
1120+ if newTitle != currentTitle {
1121+ _, err := bug.SetTitle(b, author, unixTime, newTitle, nil)
1122+ if err != nil {
1123+ return fmt.Errorf("failed to update title: %w", err)
1124+ }
1125+ changes = true
1126+ }
1127+
1128+ // Update description (first comment) if changed
1129+ if newMessage != currentMessage {
1130+ _, _, err := bug.EditCreateComment(b, author, unixTime, newMessage, nil, nil)
1131+ if err != nil {
1132+ return fmt.Errorf("failed to update description: %w", err)
1133+ }
1134+ changes = true
1135+ }
1136+
1137+ // Commit changes if any
1138+ if changes {
1139+ if err := b.Commit(repo); err != nil {
1140+ return fmt.Errorf("failed to commit changes: %w", err)
1141+ }
1142+ }
1143+
1144+ // Get short ID for display
1145+ bugID := b.Id().String()
1146+ if len(bugID) > 7 {
1147+ bugID = bugID[:7]
1148+ }
1149+
1150+ fmt.Printf("Updated bug %s: %s\n", bugID, newTitle)
1151+ return nil
1152+}
1153+
1154+// editComment edits an existing comment
1155+func editComment(repo repository.ClockedRepo, b *bug.Bug, comment *bug.Comment, author identity.Interface, unixTime int64, newMessage string) error {
1156+ currentMessage := comment.Message
1157+
1158+ // If no message provided, open editor with current content
1159+ if strings.TrimSpace(newMessage) == "" {
1160+ // Prepare template: current comment content
1161+ template := currentMessage
1162+
1163+ // Add edit instructions
1164+ template = `;; Edit your comment below
1165+;; Lines starting with ;; will be ignored
1166+;; You can use markdown formatting
1167+;; Save and close the editor when done
1168+;;
1169+` + template
1170+
1171+ content, err := launchEditorWithTemplate(template)
1172+ if err != nil {
1173+ return err
1174+ }
1175+ _, newMessage, err = parseEditContent(content, true)
1176+ if err != nil {
1177+ return err
1178+ }
1179+ }
1180+
1181+ // Validate message
1182+ if strings.TrimSpace(newMessage) == "" {
1183+ return fmt.Errorf("comment cannot be empty")
1184+ }
1185+
1186+ // Only update if changed
1187+ if newMessage != currentMessage {
1188+ // Get the target comment ID (the operation ID for this comment)
1189+ targetID := comment.TargetId()
1190+
1191+ _, _, err := bug.EditComment(b, author, unixTime, targetID, newMessage, nil, nil)
1192+ if err != nil {
1193+ return fmt.Errorf("failed to update comment: %w", err)
1194+ }
1195+
1196+ // Commit the changes
1197+ if err := b.Commit(repo); err != nil {
1198+ return fmt.Errorf("failed to commit changes: %w", err)
1199+ }
1200+ }
1201+
1202+ // Get IDs for display
1203+ bugID := b.Id().String()
1204+ commentID := comment.CombinedId().String()
1205+ if len(bugID) > 7 {
1206+ bugID = bugID[:7]
1207+ }
1208+ if len(commentID) > 7 {
1209+ commentID = commentID[:7]
1210+ }
1211+
1212+ fmt.Printf("Updated comment %s in bug %s\n", commentID, bugID)
1213+ return nil
1214+}
1215+
1216+// runAgentEdit edits an issue or comment as the agent identity
1217+// This is non-interactive and requires at least one of --title or --message
1218+func runAgentEdit(repoPath, idStr, newTitle, newMessage string) error {
1219+ // Validate that at least one field is being updated
1220+ if strings.TrimSpace(newTitle) == "" && strings.TrimSpace(newMessage) == "" {
1221+ return fmt.Errorf("either --title or --message must be provided")
1222+ }
1223+
1224+ // Resolve the ID (bug or comment)
1225+ resolved, err := resolveID(repoPath, idStr)
1226+ if err != nil {
1227+ return err
1228+ }
1229+
1230+ // Open repository for identity lookup
1231+ repo, err := repository.OpenGoGitRepo(repoPath, "", nil)
1232+ if err != nil {
1233+ return fmt.Errorf("failed to open repository: %w", err)
1234+ }
1235+ defer repo.Close()
1236+
1237+ // Get agent identity
1238+ author, err := getAgentIdentity(repo)
1239+ if err != nil {
1240+ return err
1241+ }
1242+
1243+ unixTime := time.Now().Unix()
1244+
1245+ switch resolved.Type {
1246+ case IDTypeBug:
1247+ return editBugAgent(repo, resolved.Bug, author, unixTime, newTitle, newMessage)
1248+ case IDTypeComment:
1249+ // For comments, only message is applicable
1250+ if strings.TrimSpace(newMessage) == "" {
1251+ return fmt.Errorf("--message is required when editing a comment")
1252+ }
1253+ return editCommentAgent(repo, resolved.Bug, resolved.Comment, author, unixTime, newMessage)
1254+ }
1255+
1256+ return fmt.Errorf("unknown ID type")
1257+}
1258+
1259+// editBugAgent edits an issue as the agent (non-interactive)
1260+func editBugAgent(repo repository.ClockedRepo, b *bug.Bug, author identity.Interface, unixTime int64, newTitle, newMessage string) error {
1261+ snap := b.Compile()
1262+ currentTitle := snap.Title
1263+ currentMessage := ""
1264+ if len(snap.Comments) > 0 {
1265+ currentMessage = snap.Comments[0].Message
1266+ }
1267+
1268+ // For bugs, require at least one field to be set
1269+ if strings.TrimSpace(newTitle) == "" && strings.TrimSpace(newMessage) == "" {
1270+ return fmt.Errorf("either --title or --message must be provided")
1271+ }
1272+
1273+ // Use current values for fields not provided
1274+ if strings.TrimSpace(newTitle) == "" {
1275+ newTitle = currentTitle
1276+ }
1277+ if strings.TrimSpace(newMessage) == "" {
1278+ newMessage = currentMessage
1279+ }
1280+
1281+ changes := false
1282+
1283+ // Update title if changed
1284+ if newTitle != currentTitle {
1285+ _, err := bug.SetTitle(b, author, unixTime, newTitle, nil)
1286+ if err != nil {
1287+ return fmt.Errorf("failed to update title: %w", err)
1288+ }
1289+ changes = true
1290+ }
1291+
1292+ // Update description (first comment) if changed
1293+ if newMessage != currentMessage {
1294+ _, _, err := bug.EditCreateComment(b, author, unixTime, newMessage, nil, nil)
1295+ if err != nil {
1296+ return fmt.Errorf("failed to update description: %w", err)
1297+ }
1298+ changes = true
1299+ }
1300+
1301+ // Commit changes if any
1302+ if changes {
1303+ if err := b.Commit(repo); err != nil {
1304+ return fmt.Errorf("failed to commit changes: %w", err)
1305+ }
1306+ }
1307+
1308+ // Get short ID for display
1309+ bugID := b.Id().String()
1310+ if len(bugID) > 7 {
1311+ bugID = bugID[:7]
1312+ }
1313+
1314+ fmt.Printf("Updated bug %s: %s\n", bugID, newTitle)
1315+ return nil
1316+}
1317+
1318+// editCommentAgent edits a comment as the agent (non-interactive)
1319+func editCommentAgent(repo repository.ClockedRepo, b *bug.Bug, comment *bug.Comment, author identity.Interface, unixTime int64, newMessage string) error {
1320+ currentMessage := comment.Message
1321+
1322+ // Validate message
1323+ if strings.TrimSpace(newMessage) == "" {
1324+ return fmt.Errorf("message cannot be empty")
1325+ }
1326+
1327+ // Only update if changed
1328+ if newMessage != currentMessage {
1329+ // Get the target comment ID (the operation ID for this comment)
1330+ targetID := comment.TargetId()
1331+
1332+ _, _, err := bug.EditComment(b, author, unixTime, targetID, newMessage, nil, nil)
1333+ if err != nil {
1334+ return fmt.Errorf("failed to update comment: %w", err)
1335+ }
1336+
1337+ // Commit the changes
1338+ if err := b.Commit(repo); err != nil {
1339+ return fmt.Errorf("failed to commit changes: %w", err)
1340+ }
1341+ }
1342+
1343+ // Get IDs for display
1344+ bugID := b.Id().String()
1345+ commentID := comment.CombinedId().String()
1346+ if len(bugID) > 7 {
1347+ bugID = bugID[:7]
1348+ }
1349+ if len(commentID) > 7 {
1350+ commentID = commentID[:7]
1351+ }
1352+
1353+ fmt.Printf("Updated comment %s in bug %s\n", commentID, bugID)
1354+ return nil
1355 }
1356
1357 func main() {
+446,
-0
1@@ -0,0 +1,446 @@
2+package main
3+
4+import (
5+ "bufio"
6+ "os"
7+ "os/exec"
8+ "path/filepath"
9+ "runtime"
10+ "strings"
11+ "testing"
12+
13+ "github.com/git-bug/git-bug/entities/identity"
14+ "github.com/git-bug/git-bug/repository"
15+)
16+
17+func TestPromptUser(t *testing.T) {
18+ tests := []struct {
19+ name string
20+ input string
21+ prompt string
22+ expected string
23+ expectError bool
24+ }{
25+ {
26+ name: "simple input",
27+ input: "Test User\n",
28+ prompt: "Enter name: ",
29+ expected: "Test User",
30+ },
31+ {
32+ name: "input with spaces",
33+ input: "John Doe\n",
34+ prompt: "Enter name: ",
35+ expected: "John Doe",
36+ },
37+ {
38+ name: "empty input",
39+ input: "\n",
40+ prompt: "Enter name: ",
41+ expected: "",
42+ },
43+ {
44+ name: "input with trailing newline",
45+ input: "[email protected]\n",
46+ prompt: "Enter email: ",
47+ expected: "[email protected]",
48+ },
49+ }
50+
51+ for _, tt := range tests {
52+ t.Run(tt.name, func(t *testing.T) {
53+ // Create a scanner from the test input string
54+ scanner := bufio.NewScanner(strings.NewReader(tt.input))
55+
56+ // Call promptUserWithScanner with our test scanner
57+ result, err := promptUserWithScanner(tt.prompt, scanner)
58+
59+ if tt.expectError {
60+ if err == nil {
61+ t.Errorf("expected error, got nil")
62+ }
63+ return
64+ }
65+
66+ if err != nil {
67+ t.Errorf("unexpected error: %v", err)
68+ return
69+ }
70+
71+ if result != tt.expected {
72+ t.Errorf("promptUser() = %q, want %q", result, tt.expected)
73+ }
74+ })
75+ }
76+}
77+
78+func TestDetectJJConfig(t *testing.T) {
79+ // Test case 1: No .jj directory
80+ t.Run("no_jj_directory", func(t *testing.T) {
81+ tmpDir := t.TempDir()
82+ name, email, err := detectJJConfig(tmpDir)
83+ if err != nil {
84+ t.Fatalf("unexpected error: %v", err)
85+ }
86+ if name != "" || email != "" {
87+ t.Errorf("expected empty name and email, got name=%q, email=%q", name, email)
88+ }
89+ })
90+
91+ // Test case 2: .jj directory exists (would need actual jj config in practice)
92+ t.Run("jj_directory_exists", func(t *testing.T) {
93+ // This test documents the expected behavior when .jj exists
94+ // In a real scenario, it would try to run jj commands
95+ tmpDir := t.TempDir()
96+ jjDir := filepath.Join(tmpDir, ".jj")
97+ if err := os.MkdirAll(jjDir, 0755); err != nil {
98+ t.Fatalf("failed to create .jj directory: %v", err)
99+ }
100+ // We expect the function to attempt jj config commands
101+ // Since jj is not available in test env, we just verify the directory detection
102+ _, _, err := detectJJConfig(tmpDir)
103+ // We expect an error since jj binary won't be available
104+ if err == nil {
105+ t.Skip("jj not available in test environment, skipping")
106+ }
107+ })
108+}
109+
110+func TestCreateIdentity(t *testing.T) {
111+ // Create a temporary directory for a test git repo
112+ tmpDir := t.TempDir()
113+
114+ // Initialize a git repo
115+ initCmd := exec.Command("git", "init")
116+ initCmd.Dir = tmpDir
117+ if err := initCmd.Run(); err != nil {
118+ t.Fatalf("failed to init git repo: %v", err)
119+ }
120+
121+ // Test creating an identity
122+ err := createIdentity(tmpDir, "Test User", "[email protected]", true)
123+ if err != nil {
124+ t.Fatalf("createIdentity failed: %v", err)
125+ }
126+
127+ // Verify identity was created by checking if we can get it
128+ repo, err := repository.OpenGoGitRepo(tmpDir, "", nil)
129+ if err != nil {
130+ t.Fatalf("failed to open repo: %v", err)
131+ }
132+ defer repo.Close()
133+
134+ isSet, err := identity.IsUserIdentitySet(repo)
135+ if err != nil {
136+ t.Fatalf("IsUserIdentitySet failed: %v", err)
137+ }
138+ if !isSet {
139+ t.Error("expected user identity to be set")
140+ }
141+
142+ userIdentity, err := identity.GetUserIdentity(repo)
143+ if err != nil {
144+ t.Fatalf("GetUserIdentity failed: %v", err)
145+ }
146+
147+ if userIdentity.Name() != "Test User" {
148+ t.Errorf("expected name 'Test User', got %q", userIdentity.Name())
149+ }
150+
151+ if userIdentity.Email() != "[email protected]" {
152+ t.Errorf("expected email '[email protected]', got %q", userIdentity.Email())
153+ }
154+}
155+
156+func TestInitCommand_InteractiveMode(t *testing.T) {
157+ // Create a temporary directory for a test git repo
158+ tmpDir := t.TempDir()
159+
160+ // Initialize a git repo
161+ initCmd := exec.Command("git", "init")
162+ initCmd.Dir = tmpDir
163+ if err := initCmd.Run(); err != nil {
164+ t.Fatalf("failed to init git repo: %v", err)
165+ }
166+
167+ // Test the init function with a mock input (simulating interactive mode)
168+ // Since we don't have jj, we'll test the interactive path by mocking stdin
169+ input := "Interactive User\[email protected]\n"
170+ r, w, _ := os.Pipe()
171+ go func() {
172+ w.WriteString(input)
173+ w.Close()
174+ }()
175+
176+ // We need to test runInitWithReader to inject our stdin
177+ err := runInitWithReader(tmpDir, r)
178+ if err != nil {
179+ t.Fatalf("runInitWithReader failed: %v", err)
180+ }
181+
182+ // Verify repo was opened
183+ repo, err := repository.OpenGoGitRepo(tmpDir, "", nil)
184+ if err != nil {
185+ t.Fatalf("failed to open repo: %v", err)
186+ }
187+ defer repo.Close()
188+
189+ // Check if identities were created
190+ ids, err := identity.ListLocalIds(repo)
191+ if err != nil {
192+ t.Fatalf("ListLocalIds failed: %v", err)
193+ }
194+
195+ // Should have at least 2 identities (user + agent)
196+ if len(ids) < 2 {
197+ t.Errorf("expected at least 2 identities (user + agent), got %d", len(ids))
198+ }
199+
200+ // Verify the user identity has the expected name and email
201+ userIdentity, err := identity.GetUserIdentity(repo)
202+ if err != nil {
203+ t.Fatalf("GetUserIdentity failed: %v", err)
204+ }
205+
206+ if userIdentity.Name() != "Interactive User" {
207+ t.Errorf("expected user name 'Interactive User', got %q", userIdentity.Name())
208+ }
209+
210+ if userIdentity.Email() != "[email protected]" {
211+ t.Errorf("expected user email '[email protected]', got %q", userIdentity.Email())
212+ }
213+
214+ // Verify the agent identity exists with empty email
215+ // The agent identity should be one of the identities with name "agent" and empty email
216+ foundAgent := false
217+ for _, id := range ids {
218+ i, err := identity.ReadLocal(repo, id)
219+ if err != nil {
220+ t.Fatalf("failed to read identity %s: %v", id, err)
221+ }
222+ if i.Name() == "agent" && i.Email() == "" {
223+ foundAgent = true
224+ break
225+ }
226+ }
227+ if !foundAgent {
228+ t.Error("expected to find an agent identity with empty email")
229+ }
230+}
231+
232+func TestParseEditorContent(t *testing.T) {
233+ tests := []struct {
234+ name string
235+ content string
236+ expectedTitle string
237+ expectedMessage string
238+ expectError bool
239+ }{
240+ {
241+ name: "simple title only",
242+ content: "Bug title\n",
243+ expectedTitle: "Bug title",
244+ expectedMessage: "",
245+ },
246+ {
247+ name: "title with description",
248+ content: "Bug title\n\nThis is a description\nwith multiple lines\n",
249+ expectedTitle: "Bug title",
250+ expectedMessage: "\nThis is a description\nwith multiple lines",
251+ },
252+ {
253+ name: "with comment lines",
254+ content: ";; This is a comment\nBug title\n\nDescription here\n;; Another comment",
255+ expectedTitle: "Bug title",
256+ expectedMessage: "Description here",
257+ },
258+ {
259+ name: "empty content",
260+ content: "",
261+ expectError: true,
262+ },
263+ {
264+ name: "only comments",
265+ content: ";; Comment 1\n;; Comment 2\n",
266+ expectError: true,
267+ },
268+ {
269+ name: "leading empty lines",
270+ content: "\n\n \nActual title\nDescription",
271+ expectedTitle: "Actual title",
272+ expectedMessage: "Description",
273+ },
274+ {
275+ name: "markdown headers preserved",
276+ content: "Bug title\n\n# Header 1\n## Header 2\nContent here",
277+ expectedTitle: "Bug title",
278+ expectedMessage: "\n# Header 1\n## Header 2\nContent here",
279+ },
280+ }
281+
282+ for _, tt := range tests {
283+ t.Run(tt.name, func(t *testing.T) {
284+ title, message, err := parseEditorContent(tt.content)
285+
286+ if tt.expectError {
287+ if err == nil {
288+ t.Errorf("expected error, got nil")
289+ }
290+ return
291+ }
292+
293+ if err != nil {
294+ t.Errorf("unexpected error: %v", err)
295+ return
296+ }
297+
298+ if title != tt.expectedTitle {
299+ t.Errorf("title = %q, want %q", title, tt.expectedTitle)
300+ }
301+
302+ if message != tt.expectedMessage {
303+ t.Errorf("message = %q, want %q", message, tt.expectedMessage)
304+ }
305+ })
306+ }
307+}
308+
309+func TestParseCommentContent(t *testing.T) {
310+ tests := []struct {
311+ name string
312+ content string
313+ expectedMessage string
314+ expectError bool
315+ }{
316+ {
317+ name: "simple comment",
318+ content: "This is a comment\n",
319+ expectedMessage: "This is a comment",
320+ },
321+ {
322+ name: "multiline comment",
323+ content: "First line\nSecond line\nThird line\n",
324+ expectedMessage: "First line\nSecond line\nThird line",
325+ },
326+ {
327+ name: "with comment lines",
328+ content: ";; This is a comment\nActual content\n;; Another comment",
329+ expectedMessage: "Actual content",
330+ },
331+ {
332+ name: "only comment lines",
333+ content: ";; Comment 1\n;; Comment 2\n",
334+ expectError: true,
335+ },
336+ {
337+ name: "empty content",
338+ content: "",
339+ expectError: true,
340+ },
341+ {
342+ name: "only whitespace",
343+ content: " \n\n \n",
344+ expectError: true,
345+ },
346+ {
347+ name: "leading empty lines",
348+ content: "\n\n \nActual content\nMore content",
349+ expectedMessage: "Actual content\nMore content",
350+ },
351+ {
352+ name: "trailing empty lines",
353+ content: "Actual content\nMore content\n\n \n",
354+ expectedMessage: "Actual content\nMore content",
355+ },
356+ {
357+ name: "markdown content",
358+ content: "# Header\n\n- List item 1\n- List item 2\n\n**Bold text**",
359+ expectedMessage: "# Header\n\n- List item 1\n- List item 2\n\n**Bold text**",
360+ },
361+ {
362+ name: "mixed comments and content",
363+ content: ";; Instructions here\n;; More instructions\n\nActual comment\n;; Hidden comment\nMore content\n;; End comment",
364+ expectedMessage: "Actual comment\nMore content",
365+ },
366+ }
367+
368+ for _, tt := range tests {
369+ t.Run(tt.name, func(t *testing.T) {
370+ message, err := parseCommentContent(tt.content)
371+
372+ if tt.expectError {
373+ if err == nil {
374+ t.Errorf("expected error, got nil")
375+ }
376+ return
377+ }
378+
379+ if err != nil {
380+ t.Errorf("unexpected error: %v", err)
381+ return
382+ }
383+
384+ if message != tt.expectedMessage {
385+ t.Errorf("message = %q, want %q", message, tt.expectedMessage)
386+ }
387+ })
388+ }
389+}
390+
391+func TestGetEditor(t *testing.T) {
392+ // Save original EDITOR value
393+ originalEditor := os.Getenv("EDITOR")
394+ defer os.Setenv("EDITOR", originalEditor)
395+
396+ tests := []struct {
397+ name string
398+ editorEnv string
399+ check func(t *testing.T, got string)
400+ }{
401+ {
402+ name: "with EDITOR set",
403+ editorEnv: "vim",
404+ check: func(t *testing.T, got string) {
405+ if !strings.Contains(got, "vim") {
406+ t.Errorf("getEditor() = %q, want to contain 'vim'", got)
407+ }
408+ },
409+ },
410+ {
411+ name: "with EDITOR set to path",
412+ editorEnv: "/usr/bin/nano",
413+ check: func(t *testing.T, got string) {
414+ if !strings.Contains(got, "nano") {
415+ t.Errorf("getEditor() = %q, want to contain 'nano'", got)
416+ }
417+ },
418+ },
419+ {
420+ name: "with EDITOR empty uses default",
421+ editorEnv: "",
422+ check: func(t *testing.T, got string) {
423+ if got == "" {
424+ t.Error("expected default editor, got empty string")
425+ }
426+ // Check it returns expected default based on platform
427+ if runtime.GOOS == "windows" {
428+ if got != "notepad" {
429+ t.Errorf("expected 'notepad' on Windows, got %q", got)
430+ }
431+ } else {
432+ if got != "vi" {
433+ t.Errorf("expected 'vi' on Unix, got %q", got)
434+ }
435+ }
436+ },
437+ },
438+ }
439+
440+ for _, tt := range tests {
441+ t.Run(tt.name, func(t *testing.T) {
442+ os.Setenv("EDITOR", tt.editorEnv)
443+ got := getEditor()
444+ tt.check(t, got)
445+ })
446+ }
447+}
+169,
-0
1@@ -0,0 +1,169 @@
2+//go:build integration
3+
4+package main
5+
6+import (
7+ "os/exec"
8+ "strings"
9+ "testing"
10+
11+ "github.com/git-bug/git-bug/entities/bug"
12+ "github.com/git-bug/git-bug/repository"
13+)
14+
15+// TestNewCommand_WithFlags creates a bug using command-line flags
16+func TestNewCommand_WithFlags(t *testing.T) {
17+ tmpDir := t.TempDir()
18+
19+ // Initialize a git repo
20+ initCmd := exec.Command("git", "init")
21+ initCmd.Dir = tmpDir
22+ if err := initCmd.Run(); err != nil {
23+ t.Fatalf("failed to init git repo: %v", err)
24+ }
25+
26+ // Initialize git-bug identities
27+ if err := createIdentity(tmpDir, "Test User", "[email protected]", true); err != nil {
28+ t.Fatalf("failed to create identity: %v", err)
29+ }
30+
31+ // Create a bug using flags
32+ title := "Test Bug Title"
33+ message := "Test bug description"
34+ if err := runNew(tmpDir, title, message); err != nil {
35+ t.Fatalf("runNew failed: %v", err)
36+ }
37+
38+ // Verify bug was created
39+ repo, err := repository.OpenGoGitRepo(tmpDir, "", nil)
40+ if err != nil {
41+ t.Fatalf("failed to open repo: %v", err)
42+ }
43+ defer repo.Close()
44+
45+ // Count bugs
46+ bugCount := 0
47+ for streamedBug := range bug.ReadAll(repo) {
48+ if streamedBug.Err != nil {
49+ t.Fatalf("failed to read bug: %v", streamedBug.Err)
50+ }
51+ b := streamedBug.Entity
52+ snap := b.Compile()
53+
54+ if snap.Title == title {
55+ bugCount++
56+ if snap.Comments[0].Message != message {
57+ t.Errorf("message = %q, want %q", snap.Comments[0].Message, message)
58+ }
59+ if snap.Author.Name() != "Test User" {
60+ t.Errorf("author = %q, want %q", snap.Author.Name(), "Test User")
61+ }
62+ }
63+ }
64+
65+ if bugCount != 1 {
66+ t.Errorf("expected 1 bug with title %q, found %d", title, bugCount)
67+ }
68+}
69+
70+// TestNewCommand_TitleOnly creates a bug with only title
71+func TestNewCommand_TitleOnly(t *testing.T) {
72+ tmpDir := t.TempDir()
73+
74+ // Initialize a git repo
75+ initCmd := exec.Command("git", "init")
76+ initCmd.Dir = tmpDir
77+ if err := initCmd.Run(); err != nil {
78+ t.Fatalf("failed to init git repo: %v", err)
79+ }
80+
81+ // Initialize git-bug identities
82+ if err := createIdentity(tmpDir, "Test User", "[email protected]", true); err != nil {
83+ t.Fatalf("failed to create identity: %v", err)
84+ }
85+
86+ // Create a bug with title only
87+ title := "Title Only Bug"
88+ if err := runNew(tmpDir, title, ""); err != nil {
89+ t.Fatalf("runNew failed: %v", err)
90+ }
91+
92+ // Verify bug was created
93+ repo, err := repository.OpenGoGitRepo(tmpDir, "", nil)
94+ if err != nil {
95+ t.Fatalf("failed to open repo: %v", err)
96+ }
97+ defer repo.Close()
98+
99+ // Find the bug
100+ found := false
101+ for streamedBug := range bug.ReadAll(repo) {
102+ if streamedBug.Err != nil {
103+ continue
104+ }
105+ b := streamedBug.Entity
106+ snap := b.Compile()
107+
108+ if snap.Title == title {
109+ found = true
110+ if snap.Comments[0].Message != "" {
111+ t.Errorf("expected empty message, got %q", snap.Comments[0].Message)
112+ }
113+ break
114+ }
115+ }
116+
117+ if !found {
118+ t.Errorf("bug with title %q not found", title)
119+ }
120+}
121+
122+// TestNewCommand_NoIdentityError tests error when identity not set
123+func TestNewCommand_NoIdentityError(t *testing.T) {
124+ tmpDir := t.TempDir()
125+
126+ // Initialize a git repo (but no git-bug identities)
127+ initCmd := exec.Command("git", "init")
128+ initCmd.Dir = tmpDir
129+ if err := initCmd.Run(); err != nil {
130+ t.Fatalf("failed to init git repo: %v", err)
131+ }
132+
133+ // Try to create a bug without identity
134+ err := runNew(tmpDir, "Test Title", "Test message")
135+ if err == nil {
136+ t.Error("expected error when no identity is set, got nil")
137+ }
138+
139+ if !strings.Contains(err.Error(), "failed to get user identity") {
140+ t.Errorf("expected 'failed to get user identity' error, got: %v", err)
141+ }
142+}
143+
144+// TestNewCommand_EmptyTitleError tests error for empty title
145+func TestNewCommand_EmptyTitleError(t *testing.T) {
146+ tmpDir := t.TempDir()
147+
148+ // Initialize a git repo
149+ initCmd := exec.Command("git", "init")
150+ initCmd.Dir = tmpDir
151+ if err := initCmd.Run(); err != nil {
152+ t.Fatalf("failed to init git repo: %v", err)
153+ }
154+
155+ // Initialize git-bug identities
156+ if err := createIdentity(tmpDir, "Test User", "[email protected]", true); err != nil {
157+ t.Fatalf("failed to create identity: %v", err)
158+ }
159+
160+ // Try to create a bug with empty title (this would normally trigger editor)
161+ // For direct testing, we test the validation after editor parsing
162+ err := runNew(tmpDir, " ", "") // Whitespace-only title
163+ if err == nil {
164+ t.Error("expected error for empty title, got nil")
165+ }
166+
167+ if !strings.Contains(err.Error(), "title cannot be empty") {
168+ t.Errorf("expected 'title cannot be empty' error, got: %v", err)
169+ }
170+}
+9,
-0
1@@ -6,6 +6,7 @@ import (
2 "net/url"
3 "path/filepath"
4 "regexp"
5+ "sort"
6 "time"
7
8 "github.com/git-bug/git-bug/entities/bug"
9@@ -244,6 +245,14 @@ func (c *Config) WriteIssues(pageData *PageData) error {
10 openCount := len(openIssues)
11 closedCount := len(closedIssues)
12
13+ // Sort issues by creation time, newest first
14+ sort.Slice(openIssues, func(i, j int) bool {
15+ return openIssues[i].CreatedAtISO > openIssues[j].CreatedAtISO
16+ })
17+ sort.Slice(closedIssues, func(i, j int) bool {
18+ return closedIssues[i].CreatedAtISO > closedIssues[j].CreatedAtISO
19+ })
20+
21 var sortedLabels []string
22 for label := range allLabels {
23 sortedLabels = append(sortedLabels, label)
+1,
-1
1@@ -1 +1 @@
2-5
3+7
+1,
-1
1@@ -1 +1 @@
2-21
3+27
1@@ -0,0 +1 @@
2+xK��OR045g�V*K-*���S�2�Q*��M-V��VJ*M/�M.JM,IU�2Ձ�SS2K���ku�J�2+�A�����M-��M�u��sS���BR�K�K+�t�Rs3s����32K�J�����s�t�����A*#�|��ғ-����R]B3*B�]�+��
3
4|m�j4d
1@@ -0,0 +1 @@
2+01f93c51d4f8fd152ec837b245789a8a10bb0de9
1@@ -0,0 +1 @@
2+90fe49591169951f553583fe9d00824758f5c8a1
1@@ -0,0 +1 @@
2+48c9cd3eb6995796e6720c6e4a0564e239e7d2e4
1@@ -0,0 +1 @@
2+26f17f150a7e07c6a6b642675db270cd6485a0f4
1@@ -0,0 +1 @@
2+b74d8129dfa95c1ffb85d17bc775d0cf69c53a36
1@@ -0,0 +1 @@
2+c460f63d60b94a11749991a02953371b981610d9
1@@ -0,0 +1 @@
2+a8707afc416f2fc429bc127cd96f1503f43d7055
1@@ -0,0 +1 @@
2+1afb77382653d3263b03752a799f1f873bca6924
1@@ -0,0 +1 @@
2+b0105d9a167a0e480d1648faed039c3a9627a60c
1@@ -0,0 +1 @@
2+e46ccadc03cacdfde7f1a51dec552f8f344fe11f