adds additional commands to create bugs, add comments, edit both
feat: add new command structure with flags feat: add editor launching and template parsing helpers feat: implement runNew for creating bugs test: add unit tests for editor helpers test: add fallback editor test test: add integration tests for new command feat: add helper to retrieve agent identity feat: add agent command structure with new subcommand style: remove placeholder comments from agent command feat: implement runAgentNew for agent bug creation test: add integration tests for agent command feat: complete new command for creating issues - Add 'bug new' command with --title/-t and --message/-m flags - Open $EDITOR when no flags provided - Parse first non-empty line as title, rest as description - Display success message with 7-digit short ID - Add 'bug agent new' command for automated bug creation - Use agent identity for agent commands - Add unit and integration tests fix: change comment prefix from # to ;; for markdown support The bug description accepts markdown, which uses # for headers. Changed the editor comment prefix from # to ;; so markdown headers are preserved in the description instead of being treated as comments. fix: display 7-char hex ID in success message instead of short ID Changed the success message from using variable-length short ID to always displaying the first 7 characters of the bug ID, matching the format shown in the 'bug ls' table. refactor: extract launchEditorWithTemplate for reusability feat: add resolveCommentID and resolveID helpers for edit command feat: add parseEditContent for edit command parsing feat: implement runEdit, editBug, and editComment functions feat: implement runAgentEdit, editBugAgent, and editCommentAgent functions feat: add edit and agent edit cobra commands test: add unit tests for parseEditContent test: add integration tests for agent edit command
67 files changed,  +4200, -5
M .gitignore
+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
M README.md
+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
A cmd/bug/agent_comment_integration_test.go
+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+}
A cmd/bug/agent_edit_integration_test.go
+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+}
A cmd/bug/agent_integration_test.go
+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+}
A cmd/bug/comment_integration_test.go
+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+}
A cmd/bug/edit_integration_test.go
+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+}
A cmd/bug/edit_test.go
+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+}
A cmd/bug/init_integration_test.go
+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+}
M cmd/bug/main.go
+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() {
A cmd/bug/main_test.go
+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+}
A cmd/bug/new_integration_test.go
+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+}
M issues.go
+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)
M testdata/clocks/bugs-create
+1, -1
1@@ -1 +1 @@
2-5
3+7
M testdata/clocks/bugs-edit
+1, -1
1@@ -1 +1 @@
2-21
3+27
A testdata/objects/01/46d8be52cbda8393765b16b42ecf98b4d4b2d7
+0, -0
A testdata/objects/01/f93c51d4f8fd152ec837b245789a8a10bb0de9
+0, -0
A testdata/objects/08/abd016f0d13d36bec9dc2d74c38983988a17ef
+0, -0
A testdata/objects/11/586bba36492415e4ad54c9b304e8fe458d2541
+0, -0
A testdata/objects/13/a049ba0c0d3ec9609cab1071af289d7a4b2d4a
+0, -0
A testdata/objects/13/b426a4802b5c6bf65e94a2a9fc96cd081165cf
+0, -0
A testdata/objects/1a/fb77382653d3263b03752a799f1f873bca6924
+0, -0
A testdata/objects/26/f17f150a7e07c6a6b642675db270cd6485a0f4
+0, -0
A testdata/objects/30/012cd8b70f00b5b2d330ae3ecaefef273a35cc
+0, -0
A testdata/objects/30/7843aea2d9e1704d2a31c8f471815068f3cdf0
+0, -0
A testdata/objects/34/84b54df251e4ceff8f8ad3ab6d7acb9f7d94f3
+0, -0
A testdata/objects/35/db5602f7561f63f66ce8671fa37fdda40c696c
+0, -0
A testdata/objects/37/c214c816e78ce3b73d19c842e3becfc4d91967
+0, -0
A testdata/objects/3c/afa6c6f25c5966f9553009801eb9b277830ed0
+0, -0
A testdata/objects/3d/f921a2ccaf6272087ce427ac1501298cbbff2f
+0, -0
A testdata/objects/42/06e556a3b77abf76ebf046a4e4fd1d2379fdd2
+0, -0
A testdata/objects/48/c9cd3eb6995796e6720c6e4a0564e239e7d2e4
+0, -0
A testdata/objects/4f/175c8ce2371dc30d2a975ff07e184d758f7f8c
+0, -0
A testdata/objects/55/106c593ad76f24503bd635291503adc45a780c
+0, -0
A testdata/objects/5b/95244b6c4e5da3b11ae431ff056fac2cbddfc4
+0, -0
A testdata/objects/5d/ac4e3c570f52317141025e87c6935e4946632d
+0, -0
A testdata/objects/61/b07bed9bf5b314d5c236d8382d5121b9494880
+0, -0
A testdata/objects/80/548ca2d11818fbf58b599dd23bd921d983aa08
+0, -0
A testdata/objects/83/6c2cbbbfb1a49a50568e0347ad74bc5908ba5c
+0, -0
A testdata/objects/86/a992c158e645cac82fdeb5254305a36ca7cbef
+0, -0
A testdata/objects/87/8dc9148640c71523232157b7bd5553d077ed9f
+0, -0
A testdata/objects/90/fe49591169951f553583fe9d00824758f5c8a1
+0, -0
A testdata/objects/a8/707afc416f2fc429bc127cd96f1503f43d7055
+0, -0
A testdata/objects/aa/9b4db1ac4ca852b320afc48ce5fe0982d2b483
+0, -0
A testdata/objects/ac/05a539304f1ebae88fe9b26a1bf7faadc5c7b6
+0, -0
A testdata/objects/b0/105d9a167a0e480d1648faed039c3a9627a60c
+0, -0
A testdata/objects/b4/4919f84962ce1ddda282cf9687ec2cf17e0dc0
+0, -0
A testdata/objects/b7/4d8129dfa95c1ffb85d17bc775d0cf69c53a36
+0, -0
A testdata/objects/b9/eb536afd5f63efd8bdfdf1ebde87e884d5ef63
+0, -0
A testdata/objects/bc/f77585d22a709fd919bc2f5097e205bd56ec7c
+0, -0
A testdata/objects/c3/4a89c6c7364f142f778df8d5dadd5cf9c413f3
+0, -0
A testdata/objects/c4/60f63d60b94a11749991a02953371b981610d9
+0, -0
A testdata/objects/c8/adbf309fa0653645b5e8e66bb1d908876c9778
+1, -0
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
A testdata/objects/d2/7ac13ebd8c40778ddeec7600e1a0a3e24a47ca
+0, -0
A testdata/objects/da/c916a7774f8ffc34369bafb39a626ea11c3f17
+0, -0
A testdata/objects/e0/c044679b9f369d623837125556a378f7db05f1
+0, -0
A testdata/objects/e4/6ccadc03cacdfde7f1a51dec552f8f344fe11f
+0, -0
A testdata/refs/bugs/01a1b86c3a49076ca060cab2854e8cd4794528458fb983dcef9ab2d84aaacbea
+1, -0
1@@ -0,0 +1 @@
2+01f93c51d4f8fd152ec837b245789a8a10bb0de9
A testdata/refs/bugs/aa217d585537a3da30084f3f2af6a9eb7e1672816afb73178f4c790568cd4577
+1, -0
1@@ -0,0 +1 @@
2+90fe49591169951f553583fe9d00824758f5c8a1
A testdata/refs/identities/1a37bb6eb043f6cb106a44f6e2e7acb17683784fefee07c6bbe847e56a33bafb
+1, -0
1@@ -0,0 +1 @@
2+48c9cd3eb6995796e6720c6e4a0564e239e7d2e4
A testdata/refs/identities/1f719e0eec6fcf6a247dc31a5bdaf844204c074aced200fd6e9a0490011d0d69
+1, -0
1@@ -0,0 +1 @@
2+26f17f150a7e07c6a6b642675db270cd6485a0f4
A testdata/refs/identities/5c0242e4be63e026257d1e2b7050679ea56bc387acd15f44aaccd31756a0eebd
+1, -0
1@@ -0,0 +1 @@
2+b74d8129dfa95c1ffb85d17bc775d0cf69c53a36
A testdata/refs/identities/b60fd1f95b3c70672de36a79cca33d3947b7c7d2763b8334de305b3672efe8d5
+1, -0
1@@ -0,0 +1 @@
2+c460f63d60b94a11749991a02953371b981610d9
A testdata/refs/identities/e2a081eb42b2b099b472cc698b473a4d18653117f26f9ceb713a28e14e45b59c
+1, -0
1@@ -0,0 +1 @@
2+a8707afc416f2fc429bc127cd96f1503f43d7055
A testdata/refs/identities/e86c7012dba111d0f488667fd6779906b7c9ab9a5eb19d2d46cef3d4abb57d22
+1, -0
1@@ -0,0 +1 @@
2+1afb77382653d3263b03752a799f1f873bca6924
A testdata/refs/identities/ea0aa97cfcf0693ff19bd86349932fdda2a71224fa004b0fbab5de39c1d25294
+1, -0
1@@ -0,0 +1 @@
2+b0105d9a167a0e480d1648faed039c3a9627a60c
A testdata/refs/identities/f03c612e00cd4494b13e83675394101329ff6dcd28f19a79769d055ca6eecd98
+1, -0
1@@ -0,0 +1 @@
2+e46ccadc03cacdfde7f1a51dec552f8f344fe11f