main.go
1package main
2
3import (
4 "bufio"
5 "fmt"
6 "io"
7 "os"
8 "os/exec"
9 "path/filepath"
10 "sort"
11 "strings"
12 "time"
13 "unicode/utf8"
14
15 "github.com/charmbracelet/lipgloss"
16 "github.com/dustin/go-humanize"
17 "github.com/git-bug/git-bug/cache"
18 "github.com/git-bug/git-bug/entities/bug"
19 "github.com/git-bug/git-bug/entities/identity"
20 "github.com/git-bug/git-bug/entity"
21 "github.com/git-bug/git-bug/repository"
22 "github.com/spf13/cobra"
23)
24
25// BugIssue represents a git-bug issue for display
26type BugIssue struct {
27 FullID string
28 ShortID string
29 Title string
30 Labels []string
31 CreatedAt time.Time
32 Status string
33 ParentID string // Full ID of parent bug
34}
35
36// LoadBugs loads all issues from the git-bug repository
37func LoadBugs(repoPath string) ([]BugIssue, error) {
38 repo, err := repository.OpenGoGitRepo(repoPath, "", nil)
39 if err != nil {
40 return nil, fmt.Errorf("failed to open repository: %w", err)
41 }
42 defer repo.Close()
43
44 // Open cache for parent metadata lookups (SetMetadata is only
45 // visible via BugCache.Snapshot, not via Compile). The excerpt
46 // list itself can be stale on disk (NewRepoCache prefers stored
47 // excerpts without checking for new refs), so never use AllIds
48 // as the source of truth for which bugs exist.
49 cacheInstance, events := cache.NewRepoCache(repo)
50 for ev := range events {
51 if ev.Err != nil {
52 cacheInstance.Close()
53 return nil, fmt.Errorf("failed to build bug cache: %w", ev.Err)
54 }
55 }
56 defer cacheInstance.Close()
57 bugCache := cacheInstance.Bugs()
58
59 // Generate short IDs for all issues
60 gen := NewShortIDGenerator(repoPath)
61 shortIDMap, err := gen.Generate()
62 if err != nil {
63 return nil, fmt.Errorf("failed to generate short IDs: %w", err)
64 }
65
66 var issues []BugIssue
67
68 // Enumerate via ReadAll (reads refs directly, never stale) and
69 // enrich each bug with parent metadata from the cache when available.
70 for streamedBug := range bug.ReadAll(repo) {
71 if streamedBug.Err != nil {
72 continue // Skip errors, process what we can
73 }
74
75 b := streamedBug.Entity
76 snap := b.Compile()
77
78 fullID := b.Id().String()
79 shortID, _ := shortIDMap.GetShortID(fullID)
80
81 labels := make([]string, len(snap.Labels))
82 for i, label := range snap.Labels {
83 labels[i] = string(label)
84 }
85
86 // Read parent metadata via cache snapshot when available.
87 // Fall back to no parent if the cache excerpt is stale/missing.
88 parentID := ""
89 if cached, err := bugCache.Resolve(b.Id()); err == nil {
90 if parent, ok := cached.Snapshot().GetCreateMetadata("parent"); ok {
91 parentID = parent
92 }
93 }
94
95 issues = append(issues, BugIssue{
96 FullID: fullID,
97 ShortID: shortID,
98 Title: snap.Title,
99 Labels: labels,
100 CreatedAt: snap.CreateTime,
101 Status: snap.Status.String(),
102 ParentID: parentID,
103 })
104 }
105
106 return issues, nil
107}
108
109// FilterSpec represents a filter criteria
110type FilterSpec struct {
111 Labels []string // List of labels that must all be present
112 AgeOp string // "<" or ">"
113 AgeValue time.Duration // The duration value for age comparison
114}
115
116// ParseFilter parses a filter string like "label:bug" or "age:<10d"
117func ParseFilter(filterStr string) (*FilterSpec, error) {
118 if filterStr == "" {
119 return nil, nil
120 }
121
122 spec := &FilterSpec{}
123
124 // Handle label filter: label:value or label:value1,value2,...
125 if strings.HasPrefix(filterStr, "label:") {
126 labelStr := strings.TrimPrefix(filterStr, "label:")
127 // Split by comma to support multiple labels
128 labels := strings.Split(labelStr, ",")
129 // Trim whitespace from each label
130 for i, label := range labels {
131 labels[i] = strings.TrimSpace(label)
132 }
133 spec.Labels = labels
134 return spec, nil
135 }
136
137 // Handle age filter: age:<duration>, age:>duration, or age:duration
138 if strings.HasPrefix(filterStr, "age:") {
139 ageStr := strings.TrimPrefix(filterStr, "age:")
140
141 // Check for operator
142 spec.AgeOp = "<" // Default operator
143 if strings.HasPrefix(ageStr, "<") {
144 ageStr = strings.TrimPrefix(ageStr, "<")
145 } else if strings.HasPrefix(ageStr, ">") {
146 spec.AgeOp = ">"
147 ageStr = strings.TrimPrefix(ageStr, ">")
148 }
149
150 // Parse duration like "10d", "1h", etc.
151 duration, err := time.ParseDuration(ageStr)
152 if err != nil {
153 // Try to parse days (e.g., "10d" -> "240h")
154 if strings.HasSuffix(ageStr, "d") {
155 daysStr := strings.TrimSuffix(ageStr, "d")
156 var days int
157 if _, err := fmt.Sscanf(daysStr, "%d", &days); err == nil {
158 duration = time.Duration(days) * 24 * time.Hour
159 } else {
160 return nil, fmt.Errorf("invalid age filter: %s", filterStr)
161 }
162 } else {
163 return nil, fmt.Errorf("invalid age filter: %s", filterStr)
164 }
165 }
166 spec.AgeValue = duration
167 return spec, nil
168 }
169
170 return nil, fmt.Errorf("invalid filter format: %s", filterStr)
171}
172
173// ApplyFilter filters issues based on the filter spec
174func ApplyFilter(issues []BugIssue, spec *FilterSpec) []BugIssue {
175 if spec == nil {
176 return issues
177 }
178
179 var filtered []BugIssue
180 now := time.Now()
181
182 for _, issue := range issues {
183 // Check label filter - must have ALL specified labels
184 if len(spec.Labels) > 0 {
185 hasAllLabels := true
186 for _, requiredLabel := range spec.Labels {
187 found := false
188 for _, label := range issue.Labels {
189 if label == requiredLabel {
190 found = true
191 break
192 }
193 }
194 if !found {
195 hasAllLabels = false
196 break
197 }
198 }
199 if !hasAllLabels {
200 continue
201 }
202 }
203
204 // Check age filter
205 if spec.AgeValue > 0 {
206 age := now.Sub(issue.CreatedAt)
207 if spec.AgeOp == "<" {
208 // Bugs newer than AgeValue
209 if age > spec.AgeValue {
210 continue
211 }
212 } else if spec.AgeOp == ">" {
213 // Bugs older than AgeValue
214 if age < spec.AgeValue {
215 continue
216 }
217 }
218 }
219
220 filtered = append(filtered, issue)
221 }
222
223 return filtered
224}
225
226// SortSpec represents sort criteria
227type SortSpec struct {
228 Field string // "id" or "age"
229 Direction string // "asc" or "desc"
230}
231
232// ParseSort parses a sort string like "id:asc" or "age:desc"
233func ParseSort(sortStr string) (*SortSpec, error) {
234 if sortStr == "" {
235 // Default: age:desc (oldest first)
236 return &SortSpec{Field: "age", Direction: "desc"}, nil
237 }
238
239 parts := strings.Split(sortStr, ":")
240 if len(parts) != 2 {
241 return nil, fmt.Errorf("invalid sort format: %s (expected field:direction)", sortStr)
242 }
243
244 field := strings.ToLower(parts[0])
245 direction := strings.ToLower(parts[1])
246
247 if field != "id" && field != "age" {
248 return nil, fmt.Errorf("invalid sort field: %s (expected 'id' or 'age')", field)
249 }
250
251 if direction != "asc" && direction != "desc" {
252 return nil, fmt.Errorf("invalid sort direction: %s (expected 'asc' or 'desc')", direction)
253 }
254
255 return &SortSpec{Field: field, Direction: direction}, nil
256}
257
258// ApplySort sorts issues based on the sort spec
259func ApplySort(issues []BugIssue, spec *SortSpec) {
260 if spec == nil {
261 return
262 }
263
264 sort.Slice(issues, func(i, j int) bool {
265 var less bool
266
267 switch spec.Field {
268 case "id":
269 less = issues[i].FullID < issues[j].FullID
270 case "age":
271 // less defines ascending order; descending age means oldest first.
272 less = issues[i].CreatedAt.After(issues[j].CreatedAt) // Younger first (ascending)
273 }
274
275 if spec.Direction == "desc" {
276 return !less
277 }
278 return less
279 })
280}
281
282// formatID renders the ID with the short portion in purple
283// Shows at least 7 characters, with the short ID portion colored
284func formatID(fullID, shortID string) string {
285 purpleStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#9932CC")) // Purple
286 normalStyle := lipgloss.NewStyle()
287
288 // Always show at least 7 characters
289 displayLen := 7
290 if len(shortID) > displayLen {
291 displayLen = len(shortID)
292 }
293 if displayLen > len(fullID) {
294 displayLen = len(fullID)
295 }
296
297 shortLen := len(shortID)
298 if shortLen > displayLen {
299 shortLen = displayLen
300 }
301
302 return purpleStyle.Render(fullID[:shortLen]) + normalStyle.Render(fullID[shortLen:displayLen])
303}
304
305// stripANSI removes ANSI escape codes from a string
306func stripANSI(s string) string {
307 result := make([]rune, 0, len(s))
308 inEscape := false
309 for _, r := range s {
310 if inEscape {
311 if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') {
312 inEscape = false
313 }
314 continue
315 }
316 if r == '\x1b' {
317 inEscape = true
318 continue
319 }
320 result = append(result, r)
321 }
322 return string(result)
323}
324
325// printTable prints a simple text table with styled output
326// Columns are dynamically sized with at least 2 spaces between them
327func printTable(issues []BugIssue) error {
328 // First pass: calculate column widths based on content
329 const minColSpacing = 2
330 const maxSummaryLen = 70
331
332 idWidth := len("ID")
333 summaryWidth := len("Summary")
334 labelsWidth := len("Labels")
335 ageWidth := len("Age")
336
337 // Pre-calculate all formatted values and widths
338 type rowData struct {
339 id string
340 summary string
341 labels string
342 age string
343 }
344
345 rows := make([]rowData, len(issues))
346
347 for i, issue := range issues {
348 // Format ID (with ANSI codes)
349 id := formatID(issue.FullID, issue.ShortID)
350 if w := visualLen(id); w > idWidth {
351 idWidth = w
352 }
353
354 // Format summary (truncate at 70 chars)
355 summary := truncateSummary(issue.Title, maxSummaryLen)
356 if w := visualLen(summary); w > summaryWidth {
357 summaryWidth = w
358 }
359
360 // Format labels
361 labels := strings.Join(issue.Labels, ", ")
362 if labels == "" {
363 labels = "-"
364 }
365 if w := visualLen(labels); w > labelsWidth {
366 labelsWidth = w
367 }
368
369 // Format age
370 age := humanize.Time(issue.CreatedAt)
371 if w := visualLen(age); w > ageWidth {
372 ageWidth = w
373 }
374
375 rows[i] = rowData{id: id, summary: summary, labels: labels, age: age}
376 }
377
378 // Print header
379 fmt.Printf("%s%s%s%s%s%s%s\n",
380 padRight("ID", idWidth),
381 strings.Repeat(" ", minColSpacing),
382 padRight("Summary", summaryWidth),
383 strings.Repeat(" ", minColSpacing),
384 padRight("Labels", labelsWidth),
385 strings.Repeat(" ", minColSpacing),
386 padRight("Age", ageWidth),
387 )
388
389 // Print separator
390 totalWidth := idWidth + minColSpacing + summaryWidth + minColSpacing + labelsWidth + minColSpacing + ageWidth
391 fmt.Println(strings.Repeat("-", totalWidth))
392
393 // Print rows
394 for _, row := range rows {
395 fmt.Printf("%s%s%s%s%s%s%s\n",
396 padRight(row.id, idWidth),
397 strings.Repeat(" ", minColSpacing),
398 padRight(row.summary, summaryWidth),
399 strings.Repeat(" ", minColSpacing),
400 padRight(row.labels, labelsWidth),
401 strings.Repeat(" ", minColSpacing),
402 padRight(row.age, ageWidth),
403 )
404 }
405
406 return nil
407}
408
409// TreeNode represents a node in the issue tree
410type TreeNode struct {
411 Issue BugIssue
412 Children []*TreeNode
413}
414
415// buildIssueTree builds a tree structure from flat issues
416// Returns root nodes (issues with no parent or parent not in the list)
417func buildIssueTree(issues []BugIssue) []*TreeNode {
418 // Map of full ID to TreeNode
419 nodeMap := make(map[string]*TreeNode)
420
421 // Create nodes for all issues
422 for i := range issues {
423 nodeMap[issues[i].FullID] = &TreeNode{Issue: issues[i]}
424 }
425
426 // Link children to parents
427 var roots []*TreeNode
428 for _, node := range nodeMap {
429 if node.Issue.ParentID != "" {
430 if parent, ok := nodeMap[node.Issue.ParentID]; ok {
431 parent.Children = append(parent.Children, node)
432 } else {
433 // Parent not in list, treat as root
434 roots = append(roots, node)
435 }
436 } else {
437 // No parent, treat as root
438 roots = append(roots, node)
439 }
440 }
441
442 // Salvage nodes unreachable from any root (dependency cycles can
443 // only arise from manual metadata tampering: the CLI refuses to
444 // create them). Without this they would silently vanish from display.
445 reachable := make(map[*TreeNode]bool)
446 var mark func(n *TreeNode)
447 mark = func(n *TreeNode) {
448 if reachable[n] {
449 return
450 }
451 reachable[n] = true
452 for _, child := range n.Children {
453 mark(child)
454 }
455 }
456 for _, root := range roots {
457 mark(root)
458 }
459 for _, node := range nodeMap {
460 if !reachable[node] {
461 // Break the cycle for display: show the node as a root
462 // but leave its children linked so nothing renders twice.
463 node.Children = nil
464 roots = append(roots, node)
465 mark(node)
466 }
467 }
468
469 // Sort roots by age descending (oldest first)
470 sort.Slice(roots, func(i, j int) bool {
471 return roots[i].Issue.CreatedAt.Before(roots[j].Issue.CreatedAt)
472 })
473
474 // Sort children of each node by age descending (oldest first)
475 var sortChildren func([]*TreeNode)
476 sortChildren = func(nodes []*TreeNode) {
477 sort.Slice(nodes, func(i, j int) bool {
478 return nodes[i].Issue.CreatedAt.Before(nodes[j].Issue.CreatedAt)
479 })
480 for _, n := range nodes {
481 if len(n.Children) > 0 {
482 sortChildren(n.Children)
483 }
484 }
485 }
486 sortChildren(roots)
487
488 return roots
489}
490
491// printTreeTable prints issues as a tree with indentation markers
492func printTreeTable(roots []*TreeNode) error {
493 // First pass: calculate column widths
494 const minColSpacing = 2
495 const maxSummaryLen = 70
496
497 idWidth := len("ID")
498 summaryWidth := len("Summary")
499 labelsWidth := len("Labels")
500 ageWidth := len("Age")
501
502 type rowData struct {
503 id string
504 summary string
505 labels string
506 age string
507 prefix string // Tree prefix for indentation
508 }
509
510 var allRows []rowData
511
512 // Recursive function to collect all rows with prefixes.
513 // Roots (isRoot) have no prefix; their children get tree markers,
514 // and deeper levels carry vertical guides from their ancestors.
515 var collectRows func(nodes []*TreeNode, prefix string, isRoot bool)
516 collectRows = func(nodes []*TreeNode, prefix string, isRoot bool) {
517 for i, node := range nodes {
518 isLast := i == len(nodes)-1
519 var nodePrefix string
520 if isRoot {
521 nodePrefix = ""
522 } else if isLast {
523 nodePrefix = prefix + "└─ "
524 } else {
525 nodePrefix = prefix + "├─ "
526 }
527
528 // Format ID (with ANSI codes); prefix counts in runes
529 // since tree markers are multibyte but single-width
530 id := formatID(node.Issue.FullID, node.Issue.ShortID)
531 if w := visualLen(nodePrefix) + visualLen(id); w > idWidth {
532 idWidth = w
533 }
534
535 // Format summary (truncate at 70 chars)
536 summary := truncateSummary(node.Issue.Title, maxSummaryLen)
537 if w := visualLen(summary); w > summaryWidth {
538 summaryWidth = w
539 }
540
541 // Format labels
542 labels := strings.Join(node.Issue.Labels, ", ")
543 if labels == "" {
544 labels = "-"
545 }
546 if w := visualLen(labels); w > labelsWidth {
547 labelsWidth = w
548 }
549
550 // Format age
551 age := humanize.Time(node.Issue.CreatedAt)
552 if w := visualLen(age); w > ageWidth {
553 ageWidth = w
554 }
555
556 allRows = append(allRows, rowData{
557 id: id,
558 summary: summary,
559 labels: labels,
560 age: age,
561 prefix: nodePrefix,
562 })
563
564 // Recurse into children
565 if len(node.Children) > 0 {
566 var childPrefix string
567 if isRoot {
568 childPrefix = ""
569 } else if isLast {
570 childPrefix = prefix + " "
571 } else {
572 childPrefix = prefix + "│ "
573 }
574 collectRows(node.Children, childPrefix, false)
575 }
576 }
577 }
578
579 collectRows(roots, "", true)
580
581 // Print header
582 fmt.Printf("%s%s%s%s%s%s%s\n",
583 padRight("ID", idWidth),
584 strings.Repeat(" ", minColSpacing),
585 padRight("Summary", summaryWidth),
586 strings.Repeat(" ", minColSpacing),
587 padRight("Labels", labelsWidth),
588 strings.Repeat(" ", minColSpacing),
589 padRight("Age", ageWidth),
590 )
591
592 // Print separator
593 totalWidth := idWidth + minColSpacing + summaryWidth + minColSpacing + labelsWidth + minColSpacing + ageWidth
594 fmt.Println(strings.Repeat("-", totalWidth))
595
596 // Print rows
597 for _, row := range allRows {
598 fmt.Printf("%s%s%s%s%s%s%s\n",
599 padRight(row.prefix+row.id, idWidth),
600 strings.Repeat(" ", minColSpacing),
601 padRight(row.summary, summaryWidth),
602 strings.Repeat(" ", minColSpacing),
603 padRight(row.labels, labelsWidth),
604 strings.Repeat(" ", minColSpacing),
605 padRight(row.age, ageWidth),
606 )
607 }
608
609 return nil
610}
611
612// visualLen returns the display width of a string in columns,
613// ignoring ANSI escape codes. Tree markers (├, └, │, ─) are
614// single-width runes but multi-byte in UTF-8, so byte length
615// would over-count them and misalign columns.
616func visualLen(s string) int {
617 return utf8.RuneCountInString(stripANSI(s))
618}
619
620// truncateSummary shortens s to at most max runes, counting in runes
621// so multibyte characters are never split mid-encoding.
622func truncateSummary(s string, max int) string {
623 if r := []rune(s); len(r) > max {
624 return string(r[:max-3]) + "..."
625 }
626 return s
627}
628
629// padRight pads a string to the specified width, accounting for ANSI codes
630func padRight(s string, width int) string {
631 padding := width - visualLen(s)
632 if padding < 0 {
633 padding = 0
634 }
635 return s + strings.Repeat(" ", padding)
636}
637
638// promptUser prompts the user for input with the given message
639// It reads from stdin and returns the trimmed input
640func promptUser(prompt string) (string, error) {
641 fmt.Print(prompt)
642 scanner := bufio.NewScanner(os.Stdin)
643 if scanner.Scan() {
644 return strings.TrimSpace(scanner.Text()), nil
645 }
646 if err := scanner.Err(); err != nil {
647 return "", fmt.Errorf("failed to read input: %w", err)
648 }
649 return "", nil
650}
651
652// invalidateGitBugCache deletes the git-bug cache directories to force a rebuild.
653// This ensures the `bug` command and `git-bug` binary stay in sync when either
654// is used to modify issues or identities. Failures are silent (best effort).
655//
656// NOTE: OpenGoGitRepo is always called with an empty namespace, so the
657// LocalStorage root is $Repo/.git and excerpts live in .git/cache (not
658// .git/git-bug/cache). Deleting the wrong path is a silent no-op that
659// leaves stale excerpts behind.
660func invalidateGitBugCache(repoPath string) {
661 gitDir := filepath.Join(repoPath, ".git")
662
663 // Remove cache directory (serialized excerpts)
664 cachePath := filepath.Join(gitDir, "cache")
665 _ = os.RemoveAll(cachePath)
666
667 // Remove indexes directory (bleve search indexes)
668 indexesPath := filepath.Join(gitDir, "indexes")
669 _ = os.RemoveAll(indexesPath)
670}
671
672// promptUserWithScanner prompts the user using the provided scanner
673// It prints the prompt and returns the trimmed input
674func promptUserWithScanner(prompt string, scanner *bufio.Scanner) (string, error) {
675 fmt.Print(prompt)
676 if scanner.Scan() {
677 return strings.TrimSpace(scanner.Text()), nil
678 }
679 if err := scanner.Err(); err != nil {
680 return "", fmt.Errorf("failed to read input: %w", err)
681 }
682 return "", nil
683}
684
685// getEditor returns the editor command to use for interactive editing
686// Checks $EDITOR environment variable, falls back to 'vi' or 'notepad'
687func getEditor() string {
688 if editor := os.Getenv("EDITOR"); editor != "" {
689 return editor
690 }
691 // Default editors based on OS
692 if os.PathSeparator == '\\' {
693 return "notepad"
694 }
695 return "vi"
696}
697
698// launchEditorWithTemplate opens the user's preferred editor with a custom template
699// Returns the content written by the user
700func launchEditorWithTemplate(template string) (string, error) {
701 // Create temporary file
702 tmpFile, err := os.CreateTemp("", "bug-edit-*.txt")
703 if err != nil {
704 return "", fmt.Errorf("failed to create temp file: %w", err)
705 }
706 tmpPath := tmpFile.Name()
707 defer os.Remove(tmpPath)
708
709 if _, err := tmpFile.WriteString(template); err != nil {
710 tmpFile.Close()
711 return "", fmt.Errorf("failed to write template: %w", err)
712 }
713 tmpFile.Close()
714
715 // Launch editor
716 editor := getEditor()
717 cmd := exec.Command(editor, tmpPath)
718 cmd.Stdin = os.Stdin
719 cmd.Stdout = os.Stdout
720 cmd.Stderr = os.Stderr
721
722 if err := cmd.Run(); err != nil {
723 return "", fmt.Errorf("editor failed: %w", err)
724 }
725
726 // Read the result
727 content, err := os.ReadFile(tmpPath)
728 if err != nil {
729 return "", fmt.Errorf("failed to read edited file: %w", err)
730 }
731
732 return string(content), nil
733}
734
735// launchEditor opens the user's preferred editor with a temporary file for new issues
736// Returns the content written by the user
737func launchEditor(initialContent string) (string, error) {
738 // Write initial content with instructions
739 template := `;; Enter your issue title on the first line (required)
740;; Enter the description below the title
741;; Lines starting with ;; will be ignored
742;; Save and close the editor when done
743;;
744` + initialContent
745
746 return launchEditorWithTemplate(template)
747}
748
749// parseEditorContent parses editor output into title and message
750// First non-empty line is title, rest is message
751// Lines starting with ;; are treated as comments and ignored
752func parseEditorContent(content string) (title, message string, err error) {
753 lines := strings.Split(content, "\n")
754 var nonCommentLines []string
755 commentsFiltered := false
756
757 // Filter out comment lines and collect non-empty lines
758 for _, line := range lines {
759 trimmed := strings.TrimSpace(line)
760 if strings.HasPrefix(trimmed, ";;") {
761 commentsFiltered = true
762 continue
763 }
764 nonCommentLines = append(nonCommentLines, line)
765 }
766
767 // Find first non-empty line for title
768 titleIdx := -1
769 for i, line := range nonCommentLines {
770 if strings.TrimSpace(line) != "" {
771 title = strings.TrimSpace(line)
772 titleIdx = i
773 break
774 }
775 }
776
777 if titleIdx == -1 {
778 return "", "", fmt.Errorf("no title provided")
779 }
780
781 // Rest is the message (preserve original formatting)
782 if titleIdx+1 < len(nonCommentLines) {
783 messageLines := nonCommentLines[titleIdx+1:]
784 startIdx := 0
785 // Trim leading empty lines only if comments were filtered
786 if commentsFiltered {
787 for startIdx < len(messageLines) && strings.TrimSpace(messageLines[startIdx]) == "" {
788 startIdx++
789 }
790 }
791 // Trim trailing empty lines from message
792 endIdx := len(messageLines)
793 for endIdx > startIdx && strings.TrimSpace(messageLines[endIdx-1]) == "" {
794 endIdx--
795 }
796 message = strings.Join(messageLines[startIdx:endIdx], "\n")
797 }
798
799 return title, message, nil
800}
801
802// parseEditContent parses editor output for edit operations
803// It handles two formats:
804// 1. Issue format: title on first line, blank line, then description
805// 2. Comment format: single message (when parsing comment content)
806// For issue format, returns title and message
807// For comment format, set isComment=true to skip title parsing
808func parseEditContent(content string, isComment bool) (title, message string, err error) {
809 lines := strings.Split(content, "\n")
810 var nonCommentLines []string
811 commentsFiltered := false
812
813 // Filter out comment lines and collect non-empty lines
814 for _, line := range lines {
815 trimmed := strings.TrimSpace(line)
816 if strings.HasPrefix(trimmed, ";;") {
817 commentsFiltered = true
818 continue
819 }
820 nonCommentLines = append(nonCommentLines, line)
821 }
822
823 if isComment {
824 // Comment format: just extract the message
825 startIdx := 0
826 for startIdx < len(nonCommentLines) && strings.TrimSpace(nonCommentLines[startIdx]) == "" {
827 startIdx++
828 }
829 if startIdx >= len(nonCommentLines) {
830 return "", "", fmt.Errorf("no content provided")
831 }
832 endIdx := len(nonCommentLines)
833 for endIdx > startIdx && strings.TrimSpace(nonCommentLines[endIdx-1]) == "" {
834 endIdx--
835 }
836 message = strings.Join(nonCommentLines[startIdx:endIdx], "\n")
837 return "", message, nil
838 }
839
840 // Issue format: title on first line, description follows
841 // Find first non-empty line for title
842 titleIdx := -1
843 for i, line := range nonCommentLines {
844 if strings.TrimSpace(line) != "" {
845 title = strings.TrimSpace(line)
846 titleIdx = i
847 break
848 }
849 }
850
851 if titleIdx == -1 {
852 return "", "", fmt.Errorf("no title provided")
853 }
854
855 // Rest is the message (preserve original formatting)
856 if titleIdx+1 < len(nonCommentLines) {
857 messageLines := nonCommentLines[titleIdx+1:]
858 startIdx := 0
859 // Trim leading empty lines only if comments were filtered
860 if commentsFiltered {
861 for startIdx < len(messageLines) && strings.TrimSpace(messageLines[startIdx]) == "" {
862 startIdx++
863 }
864 }
865 // Trim trailing empty lines from message
866 endIdx := len(messageLines)
867 for endIdx > startIdx && strings.TrimSpace(messageLines[endIdx-1]) == "" {
868 endIdx--
869 }
870 message = strings.Join(messageLines[startIdx:endIdx], "\n")
871 }
872
873 return title, message, nil
874}
875
876// detectJJConfig attempts to get user.name and user.email from jj config
877// Returns empty strings if .jj directory doesn't exist or config is not found
878// Note: We read the config file directly because 'jj config get' walks up the
879// directory tree and may find parent repo configs, which is not what we want.
880func detectJJConfig(repoPath string) (name, email string, err error) {
881 configPath := filepath.Join(repoPath, ".jj", "config.toml")
882
883 // Check if the config file exists
884 if _, err := os.Stat(configPath); os.IsNotExist(err) {
885 return "", "", nil
886 }
887
888 // Read the config file
889 content, err := os.ReadFile(configPath)
890 if err != nil {
891 return "", "", nil
892 }
893
894 // Parse the TOML content line by line to extract user.name and user.email
895 // We do simple parsing to avoid adding a TOML dependency
896 lines := strings.Split(string(content), "\n")
897 inUserSection := false
898
899 for _, line := range lines {
900 trimmed := strings.TrimSpace(line)
901
902 // Check if we're entering the [user] section
903 if trimmed == "[user]" {
904 inUserSection = true
905 continue
906 }
907
908 // Check if we're leaving the [user] section (new section starts)
909 if strings.HasPrefix(trimmed, "[") && trimmed != "[user]" {
910 inUserSection = false
911 continue
912 }
913
914 // Extract name and email from the user section
915 if inUserSection {
916 if strings.HasPrefix(trimmed, "name") {
917 parts := strings.SplitN(trimmed, "=", 2)
918 if len(parts) == 2 {
919 name = strings.Trim(strings.TrimSpace(parts[1]), `"`)
920 }
921 }
922 if strings.HasPrefix(trimmed, "email") {
923 parts := strings.SplitN(trimmed, "=", 2)
924 if len(parts) == 2 {
925 email = strings.Trim(strings.TrimSpace(parts[1]), `"`)
926 }
927 }
928 }
929 }
930
931 return name, email, nil
932}
933
934// createIdentity creates a new git-bug identity with the given name and email
935// If setAsUser is true, the identity will be set as the current user identity
936func createIdentity(repoPath, name, email string, setAsUser bool) error {
937 repo, err := repository.OpenGoGitRepo(repoPath, "", nil)
938 if err != nil {
939 return fmt.Errorf("failed to open repository: %w", err)
940 }
941 defer repo.Close()
942
943 newIdentity, err := identity.NewIdentity(repo, name, email)
944 if err != nil {
945 return fmt.Errorf("failed to create identity: %w", err)
946 }
947
948 if err := newIdentity.Commit(repo); err != nil {
949 return fmt.Errorf("failed to commit identity: %w", err)
950 }
951
952 if setAsUser {
953 if err := identity.SetUserIdentity(repo, newIdentity); err != nil {
954 return fmt.Errorf("failed to set user identity: %w", err)
955 }
956 }
957
958 return nil
959}
960
961// getAgentIdentity retrieves the agent identity from the repository
962// The agent identity is created during 'bug init' with name "agent" and empty email
963func getAgentIdentity(repo repository.Repo) (*identity.Identity, error) {
964 // List all local identities
965 ids, err := identity.ListLocalIds(repo)
966 if err != nil {
967 return nil, fmt.Errorf("failed to list identities: %w", err)
968 }
969
970 // Find the identity with name "agent"
971 for _, id := range ids {
972 i, err := identity.ReadLocal(repo, id)
973 if err != nil {
974 continue // Skip identities we can't read
975 }
976 if i.Name() == "agent" {
977 return i, nil
978 }
979 }
980
981 return nil, fmt.Errorf("agent identity not found. Run 'bug init' first")
982}
983
984// checkIdentitiesExist checks if user and agent identities already exist
985// Returns (userExists, agentExists, error)
986func checkIdentitiesExist(repoPath string) (bool, bool, error) {
987 repo, err := repository.OpenGoGitRepo(repoPath, "", nil)
988 if err != nil {
989 return false, false, fmt.Errorf("failed to open repository: %w", err)
990 }
991 defer repo.Close()
992
993 // Check if user identity is set
994 userExists, err := identity.IsUserIdentitySet(repo)
995 if err != nil {
996 return false, false, fmt.Errorf("failed to check user identity: %w", err)
997 }
998
999 // Check if agent identity exists
1000 agentExists := false
1001 if userExists {
1002 _, err := getAgentIdentity(repo)
1003 if err == nil {
1004 agentExists = true
1005 }
1006 }
1007
1008 return userExists, agentExists, nil
1009}
1010
1011// parseGitConfig reads and parses the git config file
1012// Returns a map of section names to their key-value pairs
1013// For sections with multiple values (like refspec), values are stored as a slice
1014func parseGitConfig(repoPath string) (map[string]map[string][]string, error) {
1015 configPath := filepath.Join(repoPath, ".git", "config")
1016
1017 data, err := os.ReadFile(configPath)
1018 if err != nil {
1019 if os.IsNotExist(err) {
1020 return make(map[string]map[string][]string), nil
1021 }
1022 return nil, fmt.Errorf("failed to read git config: %w", err)
1023 }
1024
1025 config := make(map[string]map[string][]string)
1026 var currentSection string
1027
1028 lines := strings.Split(string(data), "\n")
1029 for _, line := range lines {
1030 trimmed := strings.TrimSpace(line)
1031
1032 // Skip empty lines and comments
1033 if trimmed == "" || strings.HasPrefix(trimmed, "#") {
1034 continue
1035 }
1036
1037 // Check for section header like [remote "origin"]
1038 if strings.HasPrefix(trimmed, "[") && strings.HasSuffix(trimmed, "]") {
1039 sectionContent := trimmed[1 : len(trimmed)-1]
1040 currentSection = sectionContent
1041 if _, exists := config[currentSection]; !exists {
1042 config[currentSection] = make(map[string][]string)
1043 }
1044 continue
1045 }
1046
1047 // Parse key-value pairs within a section
1048 if currentSection != "" && strings.Contains(trimmed, "=") {
1049 parts := strings.SplitN(trimmed, "=", 2)
1050 if len(parts) == 2 {
1051 key := strings.TrimSpace(parts[0])
1052 value := strings.TrimSpace(parts[1])
1053 config[currentSection][key] = append(config[currentSection][key], value)
1054 }
1055 }
1056 }
1057
1058 return config, nil
1059}
1060
1061// writeGitConfig writes the git config back to file
1062func writeGitConfig(repoPath string, config map[string]map[string][]string) error {
1063 configPath := filepath.Join(repoPath, ".git", "config")
1064
1065 var buf strings.Builder
1066
1067 for section, values := range config {
1068 buf.WriteString("[" + section + "]\n")
1069 for key, vals := range values {
1070 for _, val := range vals {
1071 buf.WriteString("\t" + key + " = " + val + "\n")
1072 }
1073 }
1074 buf.WriteString("\n")
1075 }
1076
1077 if err := os.WriteFile(configPath, []byte(buf.String()), 0644); err != nil {
1078 return fmt.Errorf("failed to write git config: %w", err)
1079 }
1080
1081 return nil
1082}
1083
1084// hasRefspec checks if a refspec is already configured
1085func hasRefspec(config map[string][]string, refspec string) bool {
1086 for _, existing := range config["fetch"] {
1087 if existing == refspec {
1088 return true
1089 }
1090 }
1091 for _, existing := range config["push"] {
1092 if existing == refspec {
1093 return true
1094 }
1095 }
1096 return false
1097}
1098
1099// configureOriginRefspecs adds bug and identity refspecs to origin remote if not present
1100// Returns true if any changes were made, false if all refspecs already exist
1101func configureOriginRefspecs(repoPath string) (bool, error) {
1102 config, err := parseGitConfig(repoPath)
1103 if err != nil {
1104 return false, err
1105 }
1106
1107 // Check if origin remote exists
1108 originSection := `remote "origin"`
1109 if _, exists := config[originSection]; !exists {
1110 return false, fmt.Errorf("no 'origin' remote configured")
1111 }
1112
1113 // Define the refspecs we want to add
1114 fetchRefspecs := []string{
1115 "+refs/bugs/*:refs/remotes/origin/bugs/*",
1116 "+refs/identities/*:refs/remotes/origin/identities/*",
1117 }
1118 pushRefspecs := []string{
1119 "+refs/bugs/*:refs/bugs/*",
1120 "+refs/identities/*:refs/identities/*",
1121 }
1122
1123 changed := false
1124
1125 // Add fetch refspecs if not present
1126 for _, refspec := range fetchRefspecs {
1127 if !hasRefspec(config[originSection], refspec) {
1128 config[originSection]["fetch"] = append(config[originSection]["fetch"], refspec)
1129 changed = true
1130 }
1131 }
1132
1133 // Add push refspecs if not present
1134 for _, refspec := range pushRefspecs {
1135 if !hasRefspec(config[originSection], refspec) {
1136 config[originSection]["push"] = append(config[originSection]["push"], refspec)
1137 changed = true
1138 }
1139 }
1140
1141 if !changed {
1142 return false, nil
1143 }
1144
1145 if err := writeGitConfig(repoPath, config); err != nil {
1146 return false, err
1147 }
1148
1149 return true, nil
1150}
1151
1152// resolveBugID resolves a bug ID (short or full) to a bug entity
1153// It uses ShortIDMap to handle short ID resolution
1154func resolveBugID(repoPath, idStr string) (*bug.Bug, error) {
1155 repo, err := repository.OpenGoGitRepo(repoPath, "", nil)
1156 if err != nil {
1157 return nil, fmt.Errorf("failed to open repository: %w", err)
1158 }
1159 defer repo.Close()
1160
1161 // Generate short ID map for resolution
1162 gen := NewShortIDGenerator(repoPath)
1163 shortIDMap, err := gen.Generate()
1164 if err != nil {
1165 return nil, fmt.Errorf("failed to generate short IDs: %w", err)
1166 }
1167
1168 // Try to resolve the ID (could be short or full)
1169 fullID, err := shortIDMap.GetFullID(idStr)
1170 if err != nil {
1171 return nil, fmt.Errorf("failed to resolve bug ID %q: %w", idStr, err)
1172 }
1173
1174 // Read the bug using the full ID
1175 b, err := bug.Read(repo, entity.Id(fullID))
1176 if err != nil {
1177 return nil, fmt.Errorf("failed to read bug %q: %w", idStr, err)
1178 }
1179
1180 return b, nil
1181}
1182
1183// resolveCommentID resolves a comment ID (short or full) and returns the bug and comment
1184// It uses ShortIDMap to handle short ID resolution
1185func resolveCommentID(repoPath, idStr string) (*bug.Bug, *bug.Comment, error) {
1186 repo, err := repository.OpenGoGitRepo(repoPath, "", nil)
1187 if err != nil {
1188 return nil, nil, fmt.Errorf("failed to open repository: %w", err)
1189 }
1190 defer repo.Close()
1191
1192 // Generate short ID map for resolution
1193 gen := NewShortIDGenerator(repoPath)
1194 shortIDMap, err := gen.Generate()
1195 if err != nil {
1196 return nil, nil, fmt.Errorf("failed to generate short IDs: %w", err)
1197 }
1198
1199 // Try to resolve the ID (could be short or full)
1200 fullID, err := shortIDMap.GetFullID(idStr)
1201 if err != nil {
1202 return nil, nil, fmt.Errorf("failed to resolve comment ID %q: %w", idStr, err)
1203 }
1204
1205 // Parse the combined ID to get bug ID prefix and comment ID prefix
1206 combinedID := entity.CombinedId(fullID)
1207 bugIDPrefix, _ := entity.SeparateIds(string(combinedID))
1208
1209 // SeparateIds returns prefixes, not full IDs. We need to look up the full bug ID.
1210 // The bug ID prefix is 50 characters, but we need the full 64-character ID.
1211 fullBugID, err := shortIDMap.GetFullID(bugIDPrefix)
1212 if err != nil {
1213 return nil, nil, fmt.Errorf("failed to resolve bug ID from prefix %q: %w", bugIDPrefix, err)
1214 }
1215
1216 // Read the bug using the full bug ID
1217 b, err := bug.Read(repo, entity.Id(fullBugID))
1218 if err != nil {
1219 return nil, nil, fmt.Errorf("failed to read bug for comment %q: %w", idStr, err)
1220 }
1221
1222 // Search for the comment in the bug
1223 snap := b.Compile()
1224 comment, err := snap.SearchComment(combinedID)
1225 if err != nil {
1226 return nil, nil, fmt.Errorf("comment %q not found in bug: %w", idStr, err)
1227 }
1228
1229 return b, comment, nil
1230}
1231
1232// IDType represents the type of ID resolved
1233type IDType int
1234
1235const (
1236 IDTypeBug IDType = iota
1237 IDTypeComment
1238)
1239
1240// ResolvedID holds the result of ID resolution
1241type ResolvedID struct {
1242 Type IDType
1243 Bug *bug.Bug
1244 Comment *bug.Comment
1245 ID string // Full ID
1246}
1247
1248// resolveID attempts to resolve an ID as a bug ID first, then as a comment ID
1249// Returns ResolvedID with the type and appropriate data
1250func resolveID(repoPath, idStr string) (*ResolvedID, error) {
1251 // Try to resolve as bug ID first
1252 b, err := resolveBugID(repoPath, idStr)
1253 if err == nil {
1254 return &ResolvedID{
1255 Type: IDTypeBug,
1256 Bug: b,
1257 ID: b.Id().String(),
1258 }, nil
1259 }
1260
1261 // Try to resolve as comment ID
1262 b, comment, err := resolveCommentID(repoPath, idStr)
1263 if err == nil {
1264 return &ResolvedID{
1265 Type: IDTypeComment,
1266 Bug: b,
1267 Comment: comment,
1268 ID: comment.CombinedId().String(),
1269 }, nil
1270 }
1271
1272 // Neither resolved - return combined error
1273 return nil, fmt.Errorf("failed to resolve ID %q as bug or comment: %w", idStr, err)
1274}
1275
1276// launchCommentEditor opens the user's preferred editor for comment input
1277// Returns the content written by the user with comment lines filtered
1278func launchCommentEditor(initialContent string) (string, error) {
1279 // Write initial content with instructions
1280 template := `;; Enter your comment below
1281;; Lines starting with ;; will be ignored
1282;; You can use markdown formatting
1283;; Save and close the editor when done
1284;;
1285` + initialContent
1286
1287 return launchEditorWithTemplate(template)
1288}
1289
1290// runRead reads and displays a bug/issue with all its comments
1291// Outputs structured metadata followed by description and all comments
1292func runRead(repoPath, bugIDStr string) error {
1293 // Resolve bug ID
1294 b, err := resolveBugID(repoPath, bugIDStr)
1295 if err != nil {
1296 return err
1297 }
1298
1299 // Compile bug snapshot
1300 snap := b.Compile()
1301
1302 // Get the first comment (description) for author info
1303 var authorName, authorEmail string
1304 var createdAt time.Time
1305 if len(snap.Comments) > 0 {
1306 firstComment := snap.Comments[0]
1307 authorName = firstComment.Author.Name()
1308 authorEmail = firstComment.Author.Email()
1309 // Parse the formatted time string
1310 createdAt, _ = time.Parse("Mon Jan 2 15:04:05 2006 -0700", firstComment.FormatTime())
1311 }
1312 if createdAt.IsZero() {
1313 createdAt = snap.CreateTime
1314 }
1315
1316 // Format author string (name only if no email)
1317 authorStr := authorName
1318 if authorEmail != "" {
1319 authorStr = fmt.Sprintf("%s <%s>", authorName, authorEmail)
1320 }
1321
1322 // Format labels
1323 labelsStr := "-"
1324 if len(snap.Labels) > 0 {
1325 labels := make([]string, len(snap.Labels))
1326 for i, label := range snap.Labels {
1327 labels[i] = string(label)
1328 }
1329 labelsStr = strings.Join(labels, ", ")
1330 }
1331
1332 // Print metadata header
1333 fmt.Printf("Title: %s\n", snap.Title)
1334 bugID := b.Id().String()
1335 if len(bugID) > 7 {
1336 bugID = bugID[:7]
1337 }
1338 fmt.Printf("ID: %s\n", bugID)
1339 fmt.Printf("Author: %s\n", authorStr)
1340 fmt.Printf("Created: %s\n", createdAt.Format(time.RFC3339))
1341 fmt.Printf("Status: %s\n", snap.Status.String())
1342 fmt.Printf("Labels: %s\n", labelsStr)
1343 // Parent last, only if present and resolvable
1344 if parentFullID := currentParentID(snap); parentFullID != "" {
1345 parentShort := parentFullID
1346 if len(parentShort) > 7 {
1347 parentShort = parentShort[:7]
1348 }
1349 parentTitle := ""
1350 if repo, err := repository.OpenGoGitRepo(repoPath, "", nil); err == nil {
1351 if parentBug, err := bug.Read(repo, entity.Id(parentFullID)); err == nil {
1352 parentTitle = parentBug.Compile().Title
1353 }
1354 repo.Close()
1355 }
1356 if parentTitle != "" {
1357 fmt.Printf("Parent: %s - %s\n", parentShort, parentTitle)
1358 }
1359 // Omit Parent line if parent cannot be resolved (e.g. deleted/orphan)
1360 }
1361 fmt.Println()
1362
1363 // Print description (first comment)
1364 if len(snap.Comments) > 0 {
1365 description := snap.Comments[0].Message
1366 if description != "" {
1367 fmt.Println(description)
1368 }
1369
1370 // Print additional comments
1371 for i := 1; i < len(snap.Comments); i++ {
1372 comment := snap.Comments[i]
1373 commentID := comment.CombinedId().String()
1374 if len(commentID) > 7 {
1375 commentID = commentID[:7]
1376 }
1377
1378 commentAuthor := comment.Author.Name()
1379 commentDate := comment.FormatTime()
1380
1381 fmt.Println()
1382 fmt.Printf("Comment %s made by %s on %s:\n\n", commentID, commentAuthor, commentDate)
1383 fmt.Println(comment.Message)
1384 }
1385 }
1386
1387 return nil
1388}
1389
1390// parseCommentContent parses editor output for comments
1391// Lines starting with ;; are treated as comments and ignored
1392func parseCommentContent(content string) (string, error) {
1393 lines := strings.Split(content, "\n")
1394 var nonCommentLines []string
1395
1396 // Filter out comment lines
1397 for _, line := range lines {
1398 trimmed := strings.TrimSpace(line)
1399 if strings.HasPrefix(trimmed, ";;") {
1400 continue
1401 }
1402 nonCommentLines = append(nonCommentLines, line)
1403 }
1404
1405 // Find first non-empty line
1406 startIdx := 0
1407 for startIdx < len(nonCommentLines) && strings.TrimSpace(nonCommentLines[startIdx]) == "" {
1408 startIdx++
1409 }
1410
1411 // If no content left, return error
1412 if startIdx >= len(nonCommentLines) {
1413 return "", fmt.Errorf("no comment provided")
1414 }
1415
1416 // Trim trailing empty lines
1417 endIdx := len(nonCommentLines)
1418 for endIdx > startIdx && strings.TrimSpace(nonCommentLines[endIdx-1]) == "" {
1419 endIdx--
1420 }
1421
1422 // Join remaining lines
1423 message := strings.Join(nonCommentLines[startIdx:endIdx], "\n")
1424
1425 return message, nil
1426}
1427
1428// runNew creates a new bug/issue in the repository
1429// If title is empty, opens editor for interactive input
1430func runNew(repoPath, title, message, parentID string) error {
1431 // If no title provided via flags, open editor
1432 if title == "" && message == "" {
1433 content, err := launchEditor("")
1434 if err != nil {
1435 return err
1436 }
1437 title, message, err = parseEditorContent(content)
1438 if err != nil {
1439 return err
1440 }
1441 } else if title == "" {
1442 // Message provided but no title - still need editor
1443 content, err := launchEditor(message)
1444 if err != nil {
1445 return err
1446 }
1447 title, message, err = parseEditorContent(content)
1448 if err != nil {
1449 return err
1450 }
1451 }
1452 // If only title provided, message can be empty (that's OK)
1453
1454 // Validate title
1455 if strings.TrimSpace(title) == "" {
1456 return fmt.Errorf("title cannot be empty")
1457 }
1458
1459 // Open repository
1460 repo, err := repository.OpenGoGitRepo(repoPath, "", nil)
1461 if err != nil {
1462 return fmt.Errorf("failed to open repository: %w", err)
1463 }
1464 defer repo.Close()
1465
1466 // Get user identity
1467 author, err := identity.GetUserIdentity(repo)
1468 if err != nil {
1469 return fmt.Errorf("failed to get user identity: %w\n\nRun 'bug init' first to set up identities", err)
1470 }
1471
1472 // Prepare metadata for parent if provided
1473 var metadata map[string]string
1474 if parentID != "" {
1475 // Resolve parent ID to full ID
1476 parentBug, err := resolveBugID(repoPath, parentID)
1477 if err != nil {
1478 return fmt.Errorf("failed to resolve parent bug ID %q: %w", parentID, err)
1479 }
1480 metadata = map[string]string{"parent": parentBug.Id().String()}
1481 }
1482
1483 // Create cache to use NewRaw which supports metadata
1484 cacheInstance, err := cache.NewRepoCacheNoEvents(repo)
1485 if err != nil {
1486 return fmt.Errorf("failed to create cache: %w", err)
1487 }
1488 defer cacheInstance.Close()
1489
1490 // Create the bug with metadata support
1491 unixTime := time.Now().Unix()
1492 newBug, _, err := cacheInstance.Bugs().NewRaw(author, unixTime, title, message, nil, metadata)
1493 if err != nil {
1494 return fmt.Errorf("failed to create bug: %w", err)
1495 }
1496
1497 // Get the bug ID and display first 7 characters (like bug ls table)
1498 bugID := newBug.Id().String()
1499 if len(bugID) > 7 {
1500 bugID = bugID[:7]
1501 }
1502
1503 fmt.Printf("Created bug %s: %s\n", bugID, title)
1504
1505 // Invalidate cache so git-bug sees the new bug
1506 invalidateGitBugCache(repoPath)
1507
1508 return nil
1509}
1510
1511// runInit creates initial identities in the repository
1512// It attempts to get user info from jj config if .jj exists, otherwise prompts the user
1513// It also creates an agent identity
1514func runInit(repoPath string) error {
1515 return runInitWithReader(repoPath, os.Stdin)
1516}
1517
1518// ErrNoOriginRemote is returned when no origin remote is configured
1519type ErrNoOriginRemote struct{}
1520
1521func (e ErrNoOriginRemote) Error() string {
1522 return "no 'origin' remote configured"
1523}
1524
1525// runInitWithReader is the testable version that accepts an io.Reader for stdin
1526func runInitWithReader(repoPath string, reader io.Reader) error {
1527 // Check if identities already exist (idempotent behavior)
1528 userExists, agentExists, err := checkIdentitiesExist(repoPath)
1529 if err != nil {
1530 return err
1531 }
1532
1533 // Track if we need to prompt for user info
1534 var name, email string
1535
1536 if userExists {
1537 fmt.Println("User identity already exists, skipping creation")
1538 } else {
1539 // Try to get user info from jj config
1540 name, email, err = detectJJConfig(repoPath)
1541 if err != nil {
1542 return fmt.Errorf("failed to detect jj config: %w", err)
1543 }
1544
1545 // If jj config was found, use it
1546 if name != "" && email != "" {
1547 fmt.Printf("Creating user identity from jj config: %s <%s>\n", name, email)
1548 } else {
1549 // Prompt user for name and email
1550 fmt.Println("No jj config found. Please provide your information:")
1551
1552 // Create a single scanner to use for all reads
1553 scanner := bufio.NewScanner(reader)
1554
1555 name, err = promptUserWithScanner("Name: ", scanner)
1556 if err != nil {
1557 return fmt.Errorf("failed to read name: %w", err)
1558 }
1559 if name == "" {
1560 return fmt.Errorf("name cannot be empty")
1561 }
1562
1563 email, err = promptUserWithScanner("Email: ", scanner)
1564 if err != nil {
1565 return fmt.Errorf("failed to read email: %w", err)
1566 }
1567 }
1568
1569 // Create user identity
1570 fmt.Printf("Creating user identity: %s <%s>\n", name, email)
1571 if err := createIdentity(repoPath, name, email, true); err != nil {
1572 return fmt.Errorf("failed to create user identity: %w", err)
1573 }
1574 fmt.Println("User identity created successfully")
1575 }
1576
1577 if agentExists {
1578 fmt.Println("Agent identity already exists, skipping creation")
1579 } else {
1580 // Create agent identity
1581 // Empty email is intentional per spec: equivalent to `git-bug user new --name 'agent' --email ''`
1582 fmt.Println("Creating agent identity...")
1583 if err := createIdentity(repoPath, "agent", "", false); err != nil {
1584 return fmt.Errorf("failed to create agent identity: %w", err)
1585 }
1586 fmt.Println("Agent identity created successfully")
1587 }
1588
1589 // Configure git remote refspecs for origin
1590 configured, err := configureOriginRefspecs(repoPath)
1591 if err != nil {
1592 if err.Error() == "no 'origin' remote configured" {
1593 return ErrNoOriginRemote{}
1594 }
1595 return fmt.Errorf("failed to configure git refspecs: %w", err)
1596 }
1597
1598 if configured {
1599 fmt.Println("Configured git to automatically sync bugs and identities with origin")
1600 } else if userExists && agentExists {
1601 // All identities exist and refspecs are configured - nothing to do
1602 fmt.Println("Nothing to do")
1603 }
1604
1605 // Invalidate cache so git-bug sees the new identities
1606 invalidateGitBugCache(repoPath)
1607
1608 return nil
1609}
1610
1611var (
1612 repoPath string
1613 filterFlag string
1614 sortFlag string
1615 newTitle string
1616 newMessage string
1617 newParentID string
1618 agentTitle string
1619 agentMessage string
1620 agentNewParentID string
1621 commentMessage string
1622 commentBugID string
1623 agentCommentMsg string
1624 editTitle string
1625 editMessage string
1626 editParentID string
1627 agentEditTitle string
1628 agentEditMsg string
1629 agentEditParentID string
1630 statusFlag string
1631
1632 // Agent command stdin/from-file flags
1633 agentNewStdinFlag bool
1634 agentNewFromFile string
1635 agentCommentStdinFlag bool
1636 agentCommentFromFile string
1637 agentEditStdinFlag bool
1638 agentEditFromFile string
1639)
1640
1641var rootCmd = &cobra.Command{
1642 Use: "bug",
1643 Short: "A CLI for git-bug issue tracking",
1644 Long: `bug is a simplified CLI interface to git-bug issues and comments.
1645It provides an easy way to list and manage issues in your git repository.`,
1646}
1647
1648var listCmd = &cobra.Command{
1649 Use: "list",
1650 Aliases: []string{"ls"},
1651 Short: "List all issues in the repository",
1652 Long: `Display a table of all git-bug issues with their ID, summary, labels, and age.
1653
1654Issues linked to a parent via --parent are nested under it with tree
1655markers (├─, └─). Parents sort oldest first, with each parent's
1656children nested below it oldest first. Passing --sort forces a flat
1657table with that sort order instead.
1658
1659Filtering:
1660 --filter label:bug Show only issues with label "bug"
1661 --filter age:<10d Show only issues newer than 10 days
1662
1663Sorting:
1664 --sort id:asc Sort by ID (ascending)
1665 --sort id:desc Sort by ID (descending)
1666 --sort age:asc Sort by age, newest first
1667 --sort age:desc Sort by age, oldest first (default)
1668
1669Status Filtering:
1670 -S, --status open Show only open issues (default)
1671 -S, --status closed Show only closed issues
1672 -S, --status all Show all issues`,
1673 RunE: func(cmd *cobra.Command, args []string) error {
1674 // Load issues
1675 issues, err := LoadBugs(repoPath)
1676 if err != nil {
1677 return err
1678 }
1679
1680 if len(issues) == 0 {
1681 fmt.Println("No Issues")
1682 return nil
1683 }
1684
1685 // Parse and apply filter
1686 filterSpec, err := ParseFilter(filterFlag)
1687 if err != nil {
1688 return err
1689 }
1690 issues = ApplyFilter(issues, filterSpec)
1691
1692 // Apply status filter
1693 if statusFlag != "" && strings.ToLower(statusFlag) != "all" {
1694 statusLower := strings.ToLower(statusFlag)
1695 if statusLower != "open" && statusLower != "closed" {
1696 return fmt.Errorf("invalid status: %s (expected 'open', 'closed', or 'all')", statusFlag)
1697 }
1698 var filtered []BugIssue
1699 for _, issue := range issues {
1700 if strings.ToLower(issue.Status) == statusLower {
1701 filtered = append(filtered, issue)
1702 }
1703 }
1704 issues = filtered
1705 }
1706
1707 if len(issues) == 0 {
1708 fmt.Println("No Issues")
1709 return nil
1710 }
1711
1712 // Check if any issues have parent relationships
1713 hasParentRelations := false
1714 for _, issue := range issues {
1715 if issue.ParentID != "" {
1716 hasParentRelations = true
1717 break
1718 }
1719 }
1720
1721 // Parse sort flag
1722 sortSpec, err := ParseSort(sortFlag)
1723 if err != nil {
1724 return err
1725 }
1726
1727 // If user specified a sort flag, use flat table with that sort
1728 // Otherwise, if there are parent relations, use tree display
1729 // Otherwise, use flat table with default sort (age:desc)
1730 if sortFlag != "" {
1731 ApplySort(issues, sortSpec)
1732 return printTable(issues)
1733 }
1734
1735 if hasParentRelations {
1736 // Build and print tree
1737 roots := buildIssueTree(issues)
1738 return printTreeTable(roots)
1739 }
1740
1741 // Default: flat table with age:desc (already the default in ApplySort)
1742 ApplySort(issues, sortSpec)
1743 return printTable(issues)
1744 },
1745}
1746
1747var initCmd = &cobra.Command{
1748 Use: "init",
1749 Short: "Initialize git-bug identities in the repository",
1750 Long: `Initialize git-bug by creating initial identities in the repository.
1751
1752If a .jj directory exists, the command will attempt to read user.name and user.email
1753from jj config and create a user identity. Otherwise, it will prompt you interactively
1754for your name and email. It also creates an agent identity.
1755
1756This command also configures git push/fetch refspecs for the 'origin' remote
1757to automatically sync bugs and identities.
1758
1759Examples:
1760 bug init # Initialize in current directory
1761 bug init --repo /path/to/repo # Initialize in specific repository`,
1762 RunE: func(cmd *cobra.Command, args []string) error {
1763 err := runInit(repoPath)
1764 if err != nil {
1765 // Check for special error types
1766 if _, ok := err.(ErrNoOriginRemote); ok {
1767 fmt.Fprintln(os.Stderr, "Error: No 'origin' remote configured.")
1768 fmt.Fprintln(os.Stderr, "")
1769 fmt.Fprintln(os.Stderr, "Please add a remote with: git remote add origin <url>")
1770 fmt.Fprintln(os.Stderr, "Then run 'bug init' again to configure bug push/fetch refspecs.")
1771 os.Exit(1)
1772 }
1773 return err
1774 }
1775 return nil
1776 },
1777}
1778
1779var newCmd = &cobra.Command{
1780 Use: "new",
1781 Short: "Create a new issue/bug",
1782 Long: `Create a new issue in the git-bug repository.
1783
1784You can provide the title and message via flags:
1785 bug new --title "Bug title" --message "Detailed description"
1786 bug new -t "Bug title" -m "Detailed description"
1787
1788To link the new issue under a parent issue:
1789 bug new --title "Subtask" --message "Details" --parent abc1234
1790 bug new -t "Subtask" -m "Details" -p abc1234
1791
1792Or launch an editor to write them interactively:
1793 bug new
1794
1795The editor will open with a template. The first non-empty line becomes the title,
1796and the rest becomes the description.`,
1797 RunE: func(cmd *cobra.Command, args []string) error {
1798 return runNew(repoPath, newTitle, newMessage, newParentID)
1799 },
1800}
1801
1802var agentCmd = &cobra.Command{
1803 Use: "agent",
1804 Short: "Agent commands for automated bug creation",
1805 Long: `Commands for creating bugs as the agent identity.
1806
1807These commands are designed for automated/agent use and are non-interactive.
1808All agent commands use the agent identity created during 'bug init'.
1809
1810Example:
1811 bug agent new --title "Automated bug" --message "Found by CI"`,
1812}
1813
1814var agentNewCmd = &cobra.Command{
1815 Use: "new",
1816 Short: "Create a new issue as the agent",
1817 Long: `Create a new issue in the git-bug repository using the agent identity.
1818
1819This command is non-interactive and requires both --title and --message flags.
1820It uses the agent identity created during 'bug init'.
1821
1822To link the new issue under a parent issue:
1823 bug agent new --title "Subtask" --message "Details" --parent abc1234
1824
1825Example:
1826 bug agent new --title "CI Failure" --message "Build failed on commit abc123"
1827 bug agent new -t "Bug found" -m "Automated scan detected issue"`,
1828 RunE: func(cmd *cobra.Command, args []string) error {
1829 return runAgentNew(repoPath, agentTitle, agentMessage, agentNewParentID, agentNewFromFile, agentNewStdinFlag)
1830 },
1831}
1832
1833var commentCmd = &cobra.Command{
1834 Use: "comment [bugid]",
1835 Short: "Add a comment to an existing bug",
1836 Long: `Add a comment to an existing bug in the git-bug repository.
1837
1838You can provide the comment via the message flag:
1839 bug comment abc1234 --message "This is my comment"
1840 bug comment abc1234 -m "This is my comment"
1841
1842Or launch an editor to write it interactively:
1843 bug comment abc1234
1844
1845The editor will open with a template. Lines starting with ;; are ignored.
1846You can use markdown formatting in your comment.`,
1847 Args: cobra.ExactArgs(1),
1848 RunE: func(cmd *cobra.Command, args []string) error {
1849 commentBugID = args[0]
1850 return runComment(repoPath, commentBugID, commentMessage)
1851 },
1852}
1853
1854var editCmd = &cobra.Command{
1855 Use: "edit [bugid|commentid]",
1856 Short: "Edit an issue or comment",
1857 Long: `Edit an existing issue or comment in the git-bug repository.
1858
1859The ID can be a bug ID (full or short) or a comment ID (full or short).
1860The command first tries to resolve the ID as a bug, then as a comment.
1861
1862For issues:
1863 bug edit abc1234 # Open editor with current title and description
1864 bug edit abc1234 -t "New Title" # Update only the title
1865 bug edit abc1234 -m "New desc" # Update only the description
1866 bug edit abc1234 -t "Title" -m "Desc" # Update both
1867 bug edit abc1234 -p def5678 # Set the parent issue (only if none set)
1868
1869A parent link can only be added when the issue has none; parent links
1870cannot be changed or removed afterwards. Links that would close a
1871dependency cycle are rejected.
1872
1873For comments:
1874 bug edit def5678 # Open editor with current comment text
1875 bug edit def5678 -m "New text" # Update the comment
1876
1877When using the editor:
1878 - For issues: title on first line, blank line, then description
1879 - For comments: edit the comment text directly
1880 - Lines starting with ;; are ignored (instructions)`,
1881 Args: cobra.ExactArgs(1),
1882 RunE: func(cmd *cobra.Command, args []string) error {
1883 return runEdit(repoPath, args[0], editTitle, editMessage, editParentID)
1884 },
1885}
1886
1887var readCmd = &cobra.Command{
1888 Use: "read [bugid]",
1889 Aliases: []string{"show"},
1890 Short: "Display a bug/issue with all comments",
1891 Long: `Display a bug/issue and all its comments in a structured format.
1892
1893The output includes metadata (title, ID, author, creation date, status, labels, parent if set)
1894followed by the description and all comments.
1895
1896Examples:
1897 bug read abc1234 # Display bug with ID prefix abc1234
1898 bug show abc1234 # Same as above (alias)
1899 bug read --repo /path abc # Display bug from specific repository`,
1900 Args: cobra.ExactArgs(1),
1901 RunE: func(cmd *cobra.Command, args []string) error {
1902 return runRead(repoPath, args[0])
1903 },
1904}
1905
1906var rmCmd = &cobra.Command{
1907 Use: "rm [bugid|commentid]",
1908 Aliases: []string{"remove"},
1909 Short: "Remove a bug/issue by its ID",
1910 Long: `Remove a bug/issue from the git-bug repository.
1911
1912The ID can be a bug ID (full or short). Comments cannot be removed individually;
1913use 'bug rm <bug-id>' to remove the entire issue including all its comments.
1914
1915WARNING: This action is permanent and cannot be undone.
1916
1917Examples:
1918 bug rm abc1234 # Remove bug with short ID abc1234
1919 bug rm abc1234567890abcdef # Remove bug with full ID
1920 bug remove abc1234 # Same as above (alias)
1921
1922Note: If a comment ID is provided, the command will return an error explaining
1923that individual comments cannot be removed.`,
1924 Args: cobra.ExactArgs(1),
1925 RunE: func(cmd *cobra.Command, args []string) error {
1926 return runRemove(repoPath, args[0])
1927 },
1928}
1929
1930var agentRmCmd = &cobra.Command{
1931 Use: "rm [bugid]",
1932 Aliases: []string{"remove"},
1933 Short: "Remove a bug/issue as the agent",
1934 Long: `Remove a bug/issue from the git-bug repository using the agent identity.
1935
1936This command is non-interactive and uses the agent identity created during 'bug init'.
1937Comments cannot be removed individually; use 'bug agent rm <bug-id>' to remove
1938 the entire issue including all its comments.
1939
1940WARNING: This action is permanent and cannot be undone.
1941
1942Examples:
1943 bug agent rm abc1234 # Remove bug with short ID abc1234
1944 bug agent remove abc1234 # Same as above (alias)`,
1945 Args: cobra.ExactArgs(1),
1946 RunE: func(cmd *cobra.Command, args []string) error {
1947 return runRemove(repoPath, args[0])
1948 },
1949}
1950
1951var openCmd = &cobra.Command{
1952 Use: "open [bugid]",
1953 Short: "Open a closed bug/issue",
1954 Long: `Open a bug/issue by changing its status from closed to open.
1955
1956The ID can be a bug ID (full or short). This command changes the bug's
1957 status to "open".
1958
1959Examples:
1960 bug open abc1234 # Open bug with short ID abc1234
1961 bug open abc1234567890abcdef # Open bug with full ID`,
1962 Args: cobra.ExactArgs(1),
1963 RunE: func(cmd *cobra.Command, args []string) error {
1964 return runOpen(repoPath, args[0])
1965 },
1966}
1967
1968var closeCmd = &cobra.Command{
1969 Use: "close [bugid]",
1970 Short: "Close an open bug/issue",
1971 Long: `Close a bug/issue by changing its status from open to closed.
1972
1973The ID can be a bug ID (full or short). This command changes the bug's
1974 status to "closed".
1975
1976Examples:
1977 bug close abc1234 # Close bug with short ID abc1234
1978 bug close abc1234567890abcdef # Close bug with full ID`,
1979 Args: cobra.ExactArgs(1),
1980 RunE: func(cmd *cobra.Command, args []string) error {
1981 return runClose(repoPath, args[0])
1982 },
1983}
1984
1985var agentCommentCmd = &cobra.Command{
1986 Use: "comment [bugid]",
1987 Short: "Add a comment to a bug as the agent",
1988 Long: `Add a comment to an existing bug using the agent identity.
1989
1990This command is non-interactive and requires the --message flag.
1991It uses the agent identity created during 'bug init'.
1992
1993Example:
1994 bug agent comment abc1234 --message "Automated analysis complete"
1995 bug agent comment abc1234 -m "Build passed all tests"`,
1996 Args: cobra.ExactArgs(1),
1997 RunE: func(cmd *cobra.Command, args []string) error {
1998 commentBugID = args[0]
1999 return runAgentComment(repoPath, commentBugID, agentCommentMsg, agentCommentFromFile, agentCommentStdinFlag)
2000 },
2001}
2002
2003var agentEditCmd = &cobra.Command{
2004 Use: "edit [bugid|commentid]",
2005 Short: "Edit an issue or comment as the agent",
2006 Long: `Edit an existing issue or comment using the agent identity.
2007
2008This command is non-interactive and requires at least one of --title, --message, or --parent.
2009It uses the agent identity created during 'bug init'.
2010
2011For issues:
2012 bug agent edit abc1234 --title "New Title"
2013 bug agent edit abc1234 --message "New description"
2014 bug agent edit abc1234 --title "Title" --message "Description"
2015 bug agent edit abc1234 --parent def5678
2016
2017A parent link can only be added when the issue has none; parent links
2018cannot be changed or removed afterwards. Links that would close a
2019dependency cycle are rejected.
2020
2021For comments:
2022 bug agent edit def5678 --message "Updated comment text"`,
2023 Args: cobra.ExactArgs(1),
2024 RunE: func(cmd *cobra.Command, args []string) error {
2025 return runAgentEdit(repoPath, args[0], agentEditTitle, agentEditMsg, agentEditFromFile, agentEditStdinFlag, agentEditParentID)
2026 },
2027}
2028
2029var agentOpenCmd = &cobra.Command{
2030 Use: "open [bugid]",
2031 Short: "Open a closed bug/issue as the agent",
2032 Long: `Open a bug/issue by changing its status from closed to open.
2033
2034This command uses the agent identity created during 'bug init'.
2035It is non-interactive and designed for automated/agent use.
2036
2037Examples:
2038 bug agent open abc1234 # Open bug with short ID abc1234`,
2039 Args: cobra.ExactArgs(1),
2040 RunE: func(cmd *cobra.Command, args []string) error {
2041 return runAgentOpen(repoPath, args[0])
2042 },
2043}
2044
2045var agentCloseCmd = &cobra.Command{
2046 Use: "close [bugid]",
2047 Short: "Close an open bug/issue as the agent",
2048 Long: `Close a bug/issue by changing its status from open to closed.
2049
2050This command uses the agent identity created during 'bug init'.
2051It is non-interactive and designed for automated/agent use.
2052
2053Examples:
2054 bug agent close abc1234 # Close bug with short ID abc1234`,
2055 Args: cobra.ExactArgs(1),
2056 RunE: func(cmd *cobra.Command, args []string) error {
2057 return runAgentClose(repoPath, args[0])
2058 },
2059}
2060
2061var agentReadCmd = &cobra.Command{
2062 Use: "read [bugid]",
2063 Short: "Display a bug/issue with all comments",
2064 Long: `Display a bug/issue and all its comments in a structured format.
2065
2066This is the same as 'bug read' but accessible under the agent subcommand.
2067The output includes metadata (title, ID, author, creation date, status, labels, parent if set)
2068followed by the description and all comments.
2069
2070Examples:
2071 bug agent read abc1234 # Display bug with ID prefix abc1234`,
2072 Args: cobra.ExactArgs(1),
2073 RunE: func(cmd *cobra.Command, args []string) error {
2074 return runRead(repoPath, args[0])
2075 },
2076}
2077
2078func init() {
2079 rootCmd.PersistentFlags().StringVar(&repoPath, "repo", ".", "path to git repository")
2080 listCmd.Flags().StringVarP(&filterFlag, "filter", "f", "", "filter issues (label:value or age:<duration>)")
2081 listCmd.Flags().StringVarP(&sortFlag, "sort", "s", "", "sort issues (field:direction, default: age:desc)")
2082 listCmd.Flags().StringVarP(&statusFlag, "status", "S", "open", "filter by status (open|closed|all)")
2083
2084 newCmd.Flags().StringVarP(&newTitle, "title", "t", "", "issue title")
2085 newCmd.Flags().StringVarP(&newMessage, "message", "m", "", "issue description/message")
2086 newCmd.Flags().StringVarP(&newParentID, "parent", "p", "", "parent bug ID (7-char short or full ID)")
2087
2088 // Agent new command flags - both required, no shorthand to force explicit usage
2089 agentNewCmd.Flags().StringVar(&agentTitle, "title", "", "issue title (required)")
2090 agentNewCmd.Flags().StringVar(&agentMessage, "message", "", "issue description")
2091 agentNewCmd.Flags().StringVar(&agentNewParentID, "parent", "", "parent bug ID (7-char short or full ID)")
2092 agentNewCmd.Flags().BoolVar(&agentNewStdinFlag, "stdin", false, "read message from stdin")
2093 agentNewCmd.Flags().StringVar(&agentNewFromFile, "from-file", "", "read message from file")
2094 agentNewCmd.MarkFlagRequired("title")
2095
2096 // Comment command flags - message is optional (opens editor if not provided)
2097 commentCmd.Flags().StringVarP(&commentMessage, "message", "m", "", "comment message (opens editor if not provided)")
2098
2099 // Agent comment command flags - message is required (non-interactive)
2100 agentCommentCmd.Flags().StringVarP(&agentCommentMsg, "message", "m", "", "comment message")
2101 agentCommentCmd.Flags().BoolVar(&agentCommentStdinFlag, "stdin", false, "read message from stdin")
2102 agentCommentCmd.Flags().StringVar(&agentCommentFromFile, "from-file", "", "read message from file")
2103
2104 // Edit command flags - both optional (opens editor if neither provided)
2105 editCmd.Flags().StringVarP(&editTitle, "title", "t", "", "new issue title")
2106 editCmd.Flags().StringVarP(&editMessage, "message", "m", "", "new issue description or comment text")
2107 editCmd.Flags().StringVarP(&editParentID, "parent", "p", "", "parent bug ID (7-char short or full ID, issues only)")
2108
2109 // Agent edit command flags - at least one required
2110 agentEditCmd.Flags().StringVar(&agentEditTitle, "title", "", "new issue title")
2111 agentEditCmd.Flags().StringVarP(&agentEditMsg, "message", "m", "", "new issue description or comment text")
2112 agentEditCmd.Flags().StringVar(&agentEditParentID, "parent", "", "parent bug ID (7-char short or full ID, issues only)")
2113 agentEditCmd.Flags().BoolVar(&agentEditStdinFlag, "stdin", false, "read message from stdin")
2114 agentEditCmd.Flags().StringVar(&agentEditFromFile, "from-file", "", "read message from file")
2115
2116 rootCmd.AddCommand(listCmd)
2117 rootCmd.AddCommand(initCmd)
2118 rootCmd.AddCommand(newCmd)
2119 rootCmd.AddCommand(commentCmd)
2120 rootCmd.AddCommand(editCmd)
2121 rootCmd.AddCommand(readCmd)
2122 rootCmd.AddCommand(rmCmd) // NEW: Add rm command
2123 rootCmd.AddCommand(termCmd) // NEW: Add term command
2124 rootCmd.AddCommand(openCmd) // NEW: Add open command
2125 rootCmd.AddCommand(closeCmd) // NEW: Add close command
2126 rootCmd.AddCommand(versionCmd)
2127
2128 // Add agent command with its subcommands
2129 agentCmd.AddCommand(agentNewCmd)
2130 agentCmd.AddCommand(agentCommentCmd)
2131 agentCmd.AddCommand(agentEditCmd)
2132 agentCmd.AddCommand(agentReadCmd)
2133 agentCmd.AddCommand(agentRmCmd) // NEW: Add agent rm command
2134 agentCmd.AddCommand(agentOpenCmd) // NEW: Add agent open command
2135 agentCmd.AddCommand(agentCloseCmd) // NEW: Add agent close command
2136 rootCmd.AddCommand(agentCmd)
2137}
2138
2139// readMessageSource reads message content from various sources based on flags.
2140// Returns error if multiple sources are specified or if required but none provided.
2141func readMessageSource(fromFile string, useStdin bool, messageFlag string, requireMessage bool) (string, error) {
2142 // Count how many message sources are specified
2143 sourceCount := 0
2144 if fromFile != "" {
2145 sourceCount++
2146 }
2147 if useStdin {
2148 sourceCount++
2149 }
2150 if messageFlag != "" {
2151 sourceCount++
2152 }
2153
2154 // Validate that only one source is specified
2155 if sourceCount > 1 {
2156 return "", fmt.Errorf(`multiple message sources specified
2157
2158Only one of the following can be used to provide message content:
2159 --message "<string>" Provide message as a command-line string
2160 --stdin Read message from standard input
2161 --from-file <path> Read message from a file
2162
2163Examples:
2164 bug agent new --title "Bug" --message "Description here"
2165 echo "Description" | bug agent new --title "Bug" --stdin
2166 bug agent new --title "Bug" --from-file /tmp/description.md`)
2167 }
2168
2169 // If no source specified and message is required, return error
2170 if sourceCount == 0 && requireMessage {
2171 return "", fmt.Errorf(`message content required
2172
2173Provide message content using one of:
2174 --message "<string>" Provide message as a command-line string
2175 --stdin Read message from standard input
2176 --from-file <path> Read message from a file
2177
2178Run 'bug agent <command> --help' for more information.`)
2179 }
2180
2181 // Read from file if specified
2182 if fromFile != "" {
2183 content, err := os.ReadFile(fromFile)
2184 if err != nil {
2185 return "", fmt.Errorf("failed to read message from file %q: %w\n\nCheck that the file exists and is readable, then try again.", fromFile, err)
2186 }
2187 return strings.TrimSpace(string(content)), nil
2188 }
2189
2190 // Read from stdin if specified
2191 if useStdin {
2192 content, err := io.ReadAll(os.Stdin)
2193 if err != nil {
2194 return "", fmt.Errorf("failed to read message from stdin: %w\n\nEnsure stdin is properly connected and try again.", err)
2195 }
2196 return strings.TrimSpace(string(content)), nil
2197 }
2198
2199 // Fall back to message flag
2200 return messageFlag, nil
2201}
2202
2203// runAgentNew creates a new bug/issue using the agent identity
2204// Both title and message are required - this is non-interactive
2205func runAgentNew(repoPath, title, message, parentID string, fromFile string, useStdin bool) error {
2206 // Validate title (still required)
2207 if strings.TrimSpace(title) == "" {
2208 return fmt.Errorf("title cannot be empty")
2209 }
2210
2211 // Read message from appropriate source
2212 messageContent, err := readMessageSource(fromFile, useStdin, message, true)
2213 if err != nil {
2214 return err
2215 }
2216
2217 // Open repository
2218 repo, err := repository.OpenGoGitRepo(repoPath, "", nil)
2219 if err != nil {
2220 return fmt.Errorf("failed to open repository: %w", err)
2221 }
2222 defer repo.Close()
2223
2224 // Get agent identity (not user identity)
2225 author, err := getAgentIdentity(repo)
2226 if err != nil {
2227 return err
2228 }
2229
2230 // Prepare metadata for parent if provided
2231 var metadata map[string]string
2232 if parentID != "" {
2233 // Resolve parent ID to full ID
2234 parentBug, err := resolveBugID(repoPath, parentID)
2235 if err != nil {
2236 return fmt.Errorf("failed to resolve parent bug ID %q: %w", parentID, err)
2237 }
2238 metadata = map[string]string{"parent": parentBug.Id().String()}
2239 }
2240
2241 // Create cache to use NewRaw which supports metadata
2242 cacheInstance, err := cache.NewRepoCacheNoEvents(repo)
2243 if err != nil {
2244 return fmt.Errorf("failed to create cache: %w", err)
2245 }
2246 defer cacheInstance.Close()
2247
2248 // Create the bug with metadata support
2249 unixTime := time.Now().Unix()
2250 newBug, _, err := cacheInstance.Bugs().NewRaw(author, unixTime, title, messageContent, nil, metadata)
2251 if err != nil {
2252 return fmt.Errorf("failed to create bug: %w", err)
2253 }
2254
2255 // Get the bug ID and display first 7 characters (like bug ls table)
2256 bugID := newBug.Id().String()
2257 if len(bugID) > 7 {
2258 bugID = bugID[:7]
2259 }
2260
2261 fmt.Printf("Created bug %s: %s\n", bugID, title)
2262
2263 // Invalidate cache so git-bug sees the new bug
2264 invalidateGitBugCache(repoPath)
2265
2266 return nil
2267}
2268
2269// runComment adds a comment to an existing bug
2270// If message is empty, opens editor for interactive input
2271func runComment(repoPath, bugIDStr, message string) error {
2272 // If no message provided, open editor
2273 if strings.TrimSpace(message) == "" {
2274 content, err := launchCommentEditor("")
2275 if err != nil {
2276 return err
2277 }
2278 message, err = parseCommentContent(content)
2279 if err != nil {
2280 return err
2281 }
2282 }
2283
2284 // Validate message
2285 if strings.TrimSpace(message) == "" {
2286 return fmt.Errorf("comment cannot be empty")
2287 }
2288
2289 // Resolve bug ID
2290 b, err := resolveBugID(repoPath, bugIDStr)
2291 if err != nil {
2292 return err
2293 }
2294
2295 // Open repository for identity lookup
2296 repo, err := repository.OpenGoGitRepo(repoPath, "", nil)
2297 if err != nil {
2298 return fmt.Errorf("failed to open repository: %w", err)
2299 }
2300 defer repo.Close()
2301
2302 // Get user identity
2303 author, err := identity.GetUserIdentity(repo)
2304 if err != nil {
2305 return fmt.Errorf("failed to get user identity: %w\n\nRun 'bug init' first to set up identities", err)
2306 }
2307
2308 // Add the comment
2309 unixTime := time.Now().Unix()
2310 commentID, _, err := bug.AddComment(b, author, unixTime, message, nil, nil)
2311 if err != nil {
2312 return fmt.Errorf("failed to add comment: %w", err)
2313 }
2314
2315 // Commit the bug to persist the comment
2316 if err = b.Commit(repo); err != nil {
2317 return fmt.Errorf("failed to commit comment: %w", err)
2318 }
2319
2320 // Get IDs for display (first 7 characters)
2321 displayCommentID := commentID.String()
2322 displayBugID := b.Id().String()
2323 if len(displayCommentID) > 7 {
2324 displayCommentID = displayCommentID[:7]
2325 }
2326 if len(displayBugID) > 7 {
2327 displayBugID = displayBugID[:7]
2328 }
2329
2330 // Display success message
2331 fmt.Printf("Created comment %s for bug %s\n", displayCommentID, displayBugID)
2332
2333 // Invalidate cache so git-bug sees the new comment
2334 invalidateGitBugCache(repoPath)
2335
2336 return nil
2337}
2338
2339// runAgentComment adds a comment to an existing bug as the agent
2340// Message is required - this is non-interactive
2341func runAgentComment(repoPath, bugIDStr, message string, fromFile string, useStdin bool) error {
2342 // Read message from appropriate source
2343 messageContent, err := readMessageSource(fromFile, useStdin, message, true)
2344 if err != nil {
2345 return err
2346 }
2347
2348 // Resolve bug ID
2349 b, err := resolveBugID(repoPath, bugIDStr)
2350 if err != nil {
2351 return err
2352 }
2353
2354 // Open repository for identity lookup
2355 repo, err := repository.OpenGoGitRepo(repoPath, "", nil)
2356 if err != nil {
2357 return fmt.Errorf("failed to open repository: %w", err)
2358 }
2359 defer repo.Close()
2360
2361 // Get agent identity
2362 author, err := getAgentIdentity(repo)
2363 if err != nil {
2364 return err
2365 }
2366
2367 // Add the comment
2368 unixTime := time.Now().Unix()
2369 commentID, _, err := bug.AddComment(b, author, unixTime, messageContent, nil, nil)
2370 if err != nil {
2371 return fmt.Errorf("failed to add comment: %w", err)
2372 }
2373
2374 // Commit the bug to persist the comment
2375 if err = b.Commit(repo); err != nil {
2376 return fmt.Errorf("failed to commit comment: %w", err)
2377 }
2378
2379 // Get IDs for display (first 7 characters)
2380 displayCommentID := commentID.String()
2381 displayBugID := b.Id().String()
2382 if len(displayCommentID) > 7 {
2383 displayCommentID = displayCommentID[:7]
2384 }
2385 if len(displayBugID) > 7 {
2386 displayBugID = displayBugID[:7]
2387 }
2388
2389 // Display success message
2390 fmt.Printf("Created comment %s for bug %s\n", displayCommentID, displayBugID)
2391
2392 // Invalidate cache so git-bug sees the new comment
2393 invalidateGitBugCache(repoPath)
2394
2395 return nil
2396}
2397
2398// runEdit edits an issue or comment
2399// If title and message flags are provided, uses them directly
2400// Otherwise, opens an editor with current content pre-populated
2401func runEdit(repoPath, idStr, newTitle, newMessage, parentID string) error {
2402 // Resolve the ID (bug or comment)
2403 resolved, err := resolveID(repoPath, idStr)
2404 if err != nil {
2405 return err
2406 }
2407
2408 // Open repository for identity lookup
2409 repo, err := repository.OpenGoGitRepo(repoPath, "", nil)
2410 if err != nil {
2411 return fmt.Errorf("failed to open repository: %w", err)
2412 }
2413 defer repo.Close()
2414
2415 // Get user identity
2416 author, err := identity.GetUserIdentity(repo)
2417 if err != nil {
2418 return fmt.Errorf("failed to get user identity: %w\n\nRun 'bug init' first to set up identities", err)
2419 }
2420
2421 unixTime := time.Now().Unix()
2422
2423 var editErr error
2424 switch resolved.Type {
2425 case IDTypeBug:
2426 var parentFullID string
2427 if parentID != "" {
2428 parentBug, err := resolveBugID(repoPath, parentID)
2429 if err != nil {
2430 return fmt.Errorf("failed to resolve parent bug ID %q: %w\n\nUse 'bug ls' to see available bugs", parentID, err)
2431 }
2432 parentFullID = parentBug.Id().String()
2433 if parentFullID == resolved.Bug.Id().String() {
2434 return fmt.Errorf("bug cannot be its own parent")
2435 }
2436 issues, err := LoadBugs(repoPath)
2437 if err != nil {
2438 return fmt.Errorf("failed to load bugs: %w", err)
2439 }
2440 if createsParentCycle(issues, resolved.Bug.Id().String(), parentFullID) {
2441 return fmt.Errorf("setting parent %q would create a dependency cycle", parentID)
2442 }
2443 }
2444 editErr = editBug(repo, resolved.Bug, author, unixTime, newTitle, newMessage, parentFullID)
2445 case IDTypeComment:
2446 if parentID != "" {
2447 return fmt.Errorf("--parent only applies to bugs, not comments")
2448 }
2449 editErr = editComment(repo, resolved.Bug, resolved.Comment, author, unixTime, newMessage)
2450 default:
2451 return fmt.Errorf("unknown ID type")
2452 }
2453
2454 if editErr == nil {
2455 // Invalidate cache so git-bug sees the changes
2456 invalidateGitBugCache(repoPath)
2457 }
2458
2459 return editErr
2460}
2461
2462// editBug edits an issue's title and/or description
2463func editBug(repo repository.ClockedRepo, b *bug.Bug, author identity.Interface, unixTime int64, newTitle, newMessage, parentFullID string) error {
2464 snap := b.Compile()
2465 currentTitle := snap.Title
2466 currentMessage := ""
2467 if len(snap.Comments) > 0 {
2468 currentMessage = snap.Comments[0].Message
2469 }
2470
2471 // If no flags provided, open editor with current content
2472 if strings.TrimSpace(newTitle) == "" && strings.TrimSpace(newMessage) == "" && parentFullID == "" {
2473 // Prepare template: title on first line, blank line, description
2474 template := fmt.Sprintf("%s\n\n%s", currentTitle, currentMessage)
2475
2476 // Add edit instructions
2477 template = `;; Edit the title on the first line (required)
2478;; Edit the description below the blank line
2479;; Lines starting with ;; will be ignored
2480;; Save and close the editor when done
2481;;
2482` + template
2483
2484 content, err := launchEditorWithTemplate(template)
2485 if err != nil {
2486 return err
2487 }
2488 newTitle, newMessage, err = parseEditContent(content, false)
2489 if err != nil {
2490 return err
2491 }
2492 } else {
2493 // Flags provided - use current values for fields not specified
2494 if strings.TrimSpace(newTitle) == "" {
2495 newTitle = currentTitle
2496 }
2497 if strings.TrimSpace(newMessage) == "" {
2498 newMessage = currentMessage
2499 }
2500 }
2501
2502 // Validate title
2503 if strings.TrimSpace(newTitle) == "" {
2504 return fmt.Errorf("title cannot be empty")
2505 }
2506
2507 // Apply changes
2508 changes := false
2509
2510 // Update title if changed
2511 if newTitle != currentTitle {
2512 _, err := bug.SetTitle(b, author, unixTime, newTitle, nil)
2513 if err != nil {
2514 return fmt.Errorf("failed to update title: %w", err)
2515 }
2516 changes = true
2517 }
2518
2519 // Update description (first comment) if changed
2520 if newMessage != currentMessage {
2521 _, _, err := bug.EditCreateComment(b, author, unixTime, newMessage, nil, nil)
2522 if err != nil {
2523 return fmt.Errorf("failed to update description: %w", err)
2524 }
2525 changes = true
2526 }
2527
2528 // Update parent link if requested (add-only: metadata keys are immutable)
2529 parentChanged := false
2530 if parentFullID != "" {
2531 currentParent := currentParentID(snap)
2532 if currentParent != "" {
2533 if currentParent == parentFullID {
2534 // Already linked to this parent: nothing to do
2535 } else {
2536 shortParent := currentParent
2537 if len(shortParent) > 7 {
2538 shortParent = shortParent[:7]
2539 }
2540 return fmt.Errorf("bug already has a parent (%s); parent links cannot be changed or removed once set", shortParent)
2541 }
2542 } else {
2543 if len(snap.Operations) == 0 {
2544 return fmt.Errorf("cannot set parent: bug has no operations")
2545 }
2546 if err := setBugParentLink(repo, b, snap.Operations[0].Id(), parentFullID); err != nil {
2547 return err
2548 }
2549 parentChanged = true
2550 }
2551 }
2552
2553 // Commit changes if any
2554 if changes {
2555 if err := b.Commit(repo); err != nil {
2556 return fmt.Errorf("failed to commit changes: %w", err)
2557 }
2558 }
2559
2560 // Get short ID for display
2561 bugID := b.Id().String()
2562 if len(bugID) > 7 {
2563 bugID = bugID[:7]
2564 }
2565
2566 fmt.Printf("Updated bug %s: %s\n", bugID, newTitle)
2567 if parentChanged {
2568 shortParent := parentFullID
2569 if len(shortParent) > 7 {
2570 shortParent = shortParent[:7]
2571 }
2572 fmt.Printf("Set parent of bug %s to %s\n", bugID, shortParent)
2573 }
2574 return nil
2575}
2576
2577// editComment edits an existing comment
2578func editComment(repo repository.ClockedRepo, b *bug.Bug, comment *bug.Comment, author identity.Interface, unixTime int64, newMessage string) error {
2579 currentMessage := comment.Message
2580
2581 // If no message provided, open editor with current content
2582 if strings.TrimSpace(newMessage) == "" {
2583 // Prepare template: current comment content
2584 template := currentMessage
2585
2586 // Add edit instructions
2587 template = `;; Edit your comment below
2588;; Lines starting with ;; will be ignored
2589;; You can use markdown formatting
2590;; Save and close the editor when done
2591;;
2592` + template
2593
2594 content, err := launchEditorWithTemplate(template)
2595 if err != nil {
2596 return err
2597 }
2598 _, newMessage, err = parseEditContent(content, true)
2599 if err != nil {
2600 return err
2601 }
2602 }
2603
2604 // Validate message
2605 if strings.TrimSpace(newMessage) == "" {
2606 return fmt.Errorf("comment cannot be empty")
2607 }
2608
2609 // Only update if changed
2610 if newMessage != currentMessage {
2611 // Get the target comment ID (the operation ID for this comment)
2612 targetID := comment.TargetId()
2613
2614 _, _, err := bug.EditComment(b, author, unixTime, targetID, newMessage, nil, nil)
2615 if err != nil {
2616 return fmt.Errorf("failed to update comment: %w", err)
2617 }
2618
2619 // Commit the changes
2620 if err := b.Commit(repo); err != nil {
2621 return fmt.Errorf("failed to commit changes: %w", err)
2622 }
2623 }
2624
2625 // Get IDs for display
2626 bugID := b.Id().String()
2627 commentID := comment.CombinedId().String()
2628 if len(bugID) > 7 {
2629 bugID = bugID[:7]
2630 }
2631 if len(commentID) > 7 {
2632 commentID = commentID[:7]
2633 }
2634
2635 fmt.Printf("Updated comment %s in bug %s\n", commentID, bugID)
2636 return nil
2637}
2638
2639// runAgentEdit edits an issue or comment as the agent identity
2640// This is non-interactive and requires at least one of --title or --message
2641func runAgentEdit(repoPath, idStr, newTitle, newMessage string, fromFile string, useStdin bool, parentID string) error {
2642 // Read message from appropriate source if provided
2643 var messageContent string
2644 hasMessageSource := fromFile != "" || useStdin || newMessage != ""
2645 if hasMessageSource {
2646 var err error
2647 messageContent, err = readMessageSource(fromFile, useStdin, newMessage, false)
2648 if err != nil {
2649 return err
2650 }
2651 }
2652
2653 // Resolve the ID (bug or comment)
2654 resolved, err := resolveID(repoPath, idStr)
2655 if err != nil {
2656 return err
2657 }
2658
2659 // Open repository for identity lookup
2660 repo, err := repository.OpenGoGitRepo(repoPath, "", nil)
2661 if err != nil {
2662 return fmt.Errorf("failed to open repository: %w", err)
2663 }
2664 defer repo.Close()
2665
2666 // Get agent identity
2667 author, err := getAgentIdentity(repo)
2668 if err != nil {
2669 return err
2670 }
2671
2672 unixTime := time.Now().Unix()
2673
2674 var editErr error
2675 switch resolved.Type {
2676 case IDTypeBug:
2677 // For bugs, require at least title, message, or parent
2678 if strings.TrimSpace(newTitle) == "" && messageContent == "" && parentID == "" {
2679 return fmt.Errorf("at least one of --title, --message, or --parent must be provided")
2680 }
2681 var parentFullID string
2682 if parentID != "" {
2683 parentBug, err := resolveBugID(repoPath, parentID)
2684 if err != nil {
2685 return fmt.Errorf("failed to resolve parent bug ID %q: %w\n\nUse 'bug ls' to see available bugs", parentID, err)
2686 }
2687 parentFullID = parentBug.Id().String()
2688 if parentFullID == resolved.Bug.Id().String() {
2689 return fmt.Errorf("bug cannot be its own parent")
2690 }
2691 issues, err := LoadBugs(repoPath)
2692 if err != nil {
2693 return fmt.Errorf("failed to load bugs: %w", err)
2694 }
2695 if createsParentCycle(issues, resolved.Bug.Id().String(), parentFullID) {
2696 return fmt.Errorf("setting parent %q would create a dependency cycle", parentID)
2697 }
2698 }
2699 editErr = editBugAgent(repo, resolved.Bug, author, unixTime, newTitle, messageContent, parentFullID)
2700 case IDTypeComment:
2701 if parentID != "" {
2702 return fmt.Errorf("--parent only applies to bugs, not comments")
2703 }
2704 // For comments, only message is applicable
2705 if messageContent == "" {
2706 return fmt.Errorf("--message is required when editing a comment")
2707 }
2708 editErr = editCommentAgent(repo, resolved.Bug, resolved.Comment, author, unixTime, messageContent)
2709 default:
2710 return fmt.Errorf("unknown ID type")
2711 }
2712
2713 if editErr == nil {
2714 // Invalidate cache so git-bug sees the changes
2715 invalidateGitBugCache(repoPath)
2716 }
2717
2718 return editErr
2719}
2720
2721// editBugAgent edits an issue as the agent (non-interactive)
2722func editBugAgent(repo repository.ClockedRepo, b *bug.Bug, author identity.Interface, unixTime int64, newTitle, newMessage, parentFullID string) error {
2723 snap := b.Compile()
2724 currentTitle := snap.Title
2725 currentMessage := ""
2726 if len(snap.Comments) > 0 {
2727 currentMessage = snap.Comments[0].Message
2728 }
2729
2730 // For bugs, require at least one field to be set
2731 if strings.TrimSpace(newTitle) == "" && strings.TrimSpace(newMessage) == "" && parentFullID == "" {
2732 return fmt.Errorf("at least one of --title, --message, or --parent must be provided")
2733 }
2734
2735 // Use current values for fields not provided
2736 if strings.TrimSpace(newTitle) == "" {
2737 newTitle = currentTitle
2738 }
2739 if strings.TrimSpace(newMessage) == "" {
2740 newMessage = currentMessage
2741 }
2742
2743 changes := false
2744
2745 // Update title if changed
2746 if newTitle != currentTitle {
2747 _, err := bug.SetTitle(b, author, unixTime, newTitle, nil)
2748 if err != nil {
2749 return fmt.Errorf("failed to update title: %w", err)
2750 }
2751 changes = true
2752 }
2753
2754 // Update description (first comment) if changed
2755 if newMessage != currentMessage {
2756 _, _, err := bug.EditCreateComment(b, author, unixTime, newMessage, nil, nil)
2757 if err != nil {
2758 return fmt.Errorf("failed to update description: %w", err)
2759 }
2760 changes = true
2761 }
2762
2763 // Update parent link if requested (add-only: metadata keys are immutable)
2764 parentChanged := false
2765 if parentFullID != "" {
2766 currentParent := currentParentID(snap)
2767 if currentParent != "" {
2768 if currentParent == parentFullID {
2769 // Already linked to this parent: nothing to do
2770 } else {
2771 shortParent := currentParent
2772 if len(shortParent) > 7 {
2773 shortParent = shortParent[:7]
2774 }
2775 return fmt.Errorf("bug already has a parent (%s); parent links cannot be changed or removed once set", shortParent)
2776 }
2777 } else {
2778 if len(snap.Operations) == 0 {
2779 return fmt.Errorf("cannot set parent: bug has no operations")
2780 }
2781 if err := setBugParentLink(repo, b, snap.Operations[0].Id(), parentFullID); err != nil {
2782 return err
2783 }
2784 parentChanged = true
2785 }
2786 }
2787
2788 // Commit changes if any
2789 if changes {
2790 if err := b.Commit(repo); err != nil {
2791 return fmt.Errorf("failed to commit changes: %w", err)
2792 }
2793 }
2794
2795 // Get short ID for display
2796 bugID := b.Id().String()
2797 if len(bugID) > 7 {
2798 bugID = bugID[:7]
2799 }
2800
2801 fmt.Printf("Updated bug %s: %s\n", bugID, newTitle)
2802 if parentChanged {
2803 shortParent := parentFullID
2804 if len(shortParent) > 7 {
2805 shortParent = shortParent[:7]
2806 }
2807 fmt.Printf("Set parent of bug %s to %s\n", bugID, shortParent)
2808 }
2809 return nil
2810}
2811
2812// editCommentAgent edits a comment as the agent (non-interactive)
2813func editCommentAgent(repo repository.ClockedRepo, b *bug.Bug, comment *bug.Comment, author identity.Interface, unixTime int64, newMessage string) error {
2814 currentMessage := comment.Message
2815
2816 // Validate message
2817 if strings.TrimSpace(newMessage) == "" {
2818 return fmt.Errorf("message cannot be empty")
2819 }
2820
2821 // Only update if changed
2822 if newMessage != currentMessage {
2823 // Get the target comment ID (the operation ID for this comment)
2824 targetID := comment.TargetId()
2825
2826 _, _, err := bug.EditComment(b, author, unixTime, targetID, newMessage, nil, nil)
2827 if err != nil {
2828 return fmt.Errorf("failed to update comment: %w", err)
2829 }
2830
2831 // Commit the changes
2832 if err := b.Commit(repo); err != nil {
2833 return fmt.Errorf("failed to commit changes: %w", err)
2834 }
2835 }
2836
2837 // Get IDs for display
2838 bugID := b.Id().String()
2839 commentID := comment.CombinedId().String()
2840 if len(bugID) > 7 {
2841 bugID = bugID[:7]
2842 }
2843 if len(commentID) > 7 {
2844 commentID = commentID[:7]
2845 }
2846
2847 fmt.Printf("Updated comment %s in bug %s\n", commentID, bugID)
2848 return nil
2849}
2850
2851// runOpen opens a bug by changing its status to Open
2852func runOpen(repoPath, bugIDStr string) error {
2853 // Resolve bug ID
2854 b, err := resolveBugID(repoPath, bugIDStr)
2855 if err != nil {
2856 return err
2857 }
2858
2859 // Open repository for identity lookup
2860 repo, err := repository.OpenGoGitRepo(repoPath, "", nil)
2861 if err != nil {
2862 return fmt.Errorf("failed to open repository: %w", err)
2863 }
2864 defer repo.Close()
2865
2866 // Get user identity
2867 author, err := identity.GetUserIdentity(repo)
2868 if err != nil {
2869 return fmt.Errorf("failed to get user identity: %w\n\nRun 'bug init' first to set up identities", err)
2870 }
2871
2872 // Open the bug
2873 unixTime := time.Now().Unix()
2874 _, err = bug.Open(b, author, unixTime, nil)
2875 if err != nil {
2876 return fmt.Errorf("failed to open bug: %w", err)
2877 }
2878
2879 // Commit the changes
2880 if err := b.Commit(repo); err != nil {
2881 return fmt.Errorf("failed to commit changes: %w", err)
2882 }
2883
2884 // Get short ID for display
2885 bugID := b.Id().String()
2886 if len(bugID) > 7 {
2887 bugID = bugID[:7]
2888 }
2889
2890 fmt.Printf("Opened bug %s: %s\n", bugID, b.Compile().Title)
2891
2892 // Invalidate cache so git-bug sees the changes
2893 invalidateGitBugCache(repoPath)
2894
2895 return nil
2896}
2897
2898// runClose closes a bug by changing its status to Closed
2899func runClose(repoPath, bugIDStr string) error {
2900 // Resolve bug ID
2901 b, err := resolveBugID(repoPath, bugIDStr)
2902 if err != nil {
2903 return err
2904 }
2905
2906 // Open repository for identity lookup
2907 repo, err := repository.OpenGoGitRepo(repoPath, "", nil)
2908 if err != nil {
2909 return fmt.Errorf("failed to open repository: %w", err)
2910 }
2911 defer repo.Close()
2912
2913 // Get user identity
2914 author, err := identity.GetUserIdentity(repo)
2915 if err != nil {
2916 return fmt.Errorf("failed to get user identity: %w\n\nRun 'bug init' first to set up identities", err)
2917 }
2918
2919 // Close the bug
2920 unixTime := time.Now().Unix()
2921 _, err = bug.Close(b, author, unixTime, nil)
2922 if err != nil {
2923 return fmt.Errorf("failed to close bug: %w", err)
2924 }
2925
2926 // Commit the changes
2927 if err := b.Commit(repo); err != nil {
2928 return fmt.Errorf("failed to commit changes: %w", err)
2929 }
2930
2931 // Get short ID for display
2932 bugID := b.Id().String()
2933 if len(bugID) > 7 {
2934 bugID = bugID[:7]
2935 }
2936
2937 fmt.Printf("Closed bug %s: %s\n", bugID, b.Compile().Title)
2938
2939 // Invalidate cache so git-bug sees the changes
2940 invalidateGitBugCache(repoPath)
2941
2942 return nil
2943}
2944
2945// runAgentOpen opens a bug using the agent identity
2946func runAgentOpen(repoPath, bugIDStr string) error {
2947 // Resolve bug ID
2948 b, err := resolveBugID(repoPath, bugIDStr)
2949 if err != nil {
2950 return err
2951 }
2952
2953 // Open repository for identity lookup
2954 repo, err := repository.OpenGoGitRepo(repoPath, "", nil)
2955 if err != nil {
2956 return fmt.Errorf("failed to open repository: %w", err)
2957 }
2958 defer repo.Close()
2959
2960 // Get agent identity
2961 author, err := getAgentIdentity(repo)
2962 if err != nil {
2963 return err
2964 }
2965
2966 // Open the bug
2967 unixTime := time.Now().Unix()
2968 _, err = bug.Open(b, author, unixTime, nil)
2969 if err != nil {
2970 return fmt.Errorf("failed to open bug: %w", err)
2971 }
2972
2973 // Commit the changes
2974 if err := b.Commit(repo); err != nil {
2975 return fmt.Errorf("failed to commit changes: %w", err)
2976 }
2977
2978 // Get short ID for display
2979 bugID := b.Id().String()
2980 if len(bugID) > 7 {
2981 bugID = bugID[:7]
2982 }
2983
2984 fmt.Printf("Opened bug %s: %s\n", bugID, b.Compile().Title)
2985
2986 // Invalidate cache so git-bug sees the changes
2987 invalidateGitBugCache(repoPath)
2988
2989 return nil
2990}
2991
2992// runAgentClose closes a bug using the agent identity
2993func runAgentClose(repoPath, bugIDStr string) error {
2994 // Resolve bug ID
2995 b, err := resolveBugID(repoPath, bugIDStr)
2996 if err != nil {
2997 return err
2998 }
2999
3000 // Open repository for identity lookup
3001 repo, err := repository.OpenGoGitRepo(repoPath, "", nil)
3002 if err != nil {
3003 return fmt.Errorf("failed to open repository: %w", err)
3004 }
3005 defer repo.Close()
3006
3007 // Get agent identity
3008 author, err := getAgentIdentity(repo)
3009 if err != nil {
3010 return err
3011 }
3012
3013 // Close the bug
3014 unixTime := time.Now().Unix()
3015 _, err = bug.Close(b, author, unixTime, nil)
3016 if err != nil {
3017 return fmt.Errorf("failed to close bug: %w", err)
3018 }
3019
3020 // Commit the changes
3021 if err := b.Commit(repo); err != nil {
3022 return fmt.Errorf("failed to commit changes: %w", err)
3023 }
3024
3025 // Get short ID for display
3026 bugID := b.Id().String()
3027 if len(bugID) > 7 {
3028 bugID = bugID[:7]
3029 }
3030
3031 fmt.Printf("Closed bug %s: %s\n", bugID, b.Compile().Title)
3032
3033 // Invalidate cache so git-bug sees the changes
3034 invalidateGitBugCache(repoPath)
3035
3036 return nil
3037}
3038
3039// runRemove removes a bug by its ID
3040// Resolves ID as bug first, then as comment (though comments cannot be removed individually)
3041func runRemove(repoPath, idStr string) error {
3042 // Resolve the ID (bug or comment)
3043 resolved, err := resolveID(repoPath, idStr)
3044 if err != nil {
3045 return err
3046 }
3047
3048 // Open repository
3049 repo, err := repository.OpenGoGitRepo(repoPath, "", nil)
3050 if err != nil {
3051 return fmt.Errorf("failed to open repository: %w", err)
3052 }
3053 defer repo.Close()
3054
3055 switch resolved.Type {
3056 case IDTypeBug:
3057 // Remove the bug
3058 if err := bug.Remove(repo, resolved.Bug.Id()); err != nil {
3059 return fmt.Errorf("failed to remove bug: %w", err)
3060 }
3061
3062 // Get short ID for display
3063 bugID := resolved.Bug.Id().String()
3064 if len(bugID) > 7 {
3065 bugID = bugID[:7]
3066 }
3067
3068 fmt.Printf("Removed bug %s: %s\n", bugID, resolved.Bug.Compile().Title)
3069
3070 case IDTypeComment:
3071 // Comments cannot be removed individually in git-bug
3072 return fmt.Errorf("removing individual comments is not supported; use 'bug rm <bug-id>' to remove the entire issue")
3073 default:
3074 return fmt.Errorf("unknown ID type")
3075 }
3076
3077 // Invalidate cache so git-bug sees the changes
3078 invalidateGitBugCache(repoPath)
3079
3080 return nil
3081}
3082
3083func main() {
3084 // Get absolute path for repo
3085 if repoPath == "." {
3086 if cwd, err := os.Getwd(); err == nil {
3087 repoPath = cwd
3088 }
3089 }
3090 repoPath, _ = filepath.Abs(repoPath)
3091
3092 if err := rootCmd.Execute(); err != nil {
3093 fmt.Fprintln(os.Stderr, err)
3094 os.Exit(1)
3095 }
3096}