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}