+2,
-0
1@@ -12,3 +12,5 @@ devenv.local.yaml
2
3 # pre-commit
4 .pre-commit-config.yaml
5+update-flake-lock
6+update-vendor-hash
+11,
-0
1@@ -44,6 +44,9 @@ bug list --status closed # Show only closed issues
2 bug list --status all # Show all issues
3 ```
4
5+**Parent/child tree:**
6+Issues created with `--parent` are nested under their parent with tree markers (`├─`, `└─`). Parents sort newest first, with each parent's children nested below it newest first. Passing `--sort` forces a flat table with that sort order instead.
7+
8 ### Create a New Issue
9
10 **Using flags:**
11@@ -59,6 +62,14 @@ bug new
12
13 The editor opens with a template. The first non-empty line becomes the title, and the rest becomes the description. Lines starting with `;;` are ignored.
14
15+**Linking to a parent issue:**
16+```bash
17+bug new --title "Subtask" --message "Details" --parent abc1234
18+bug new -t "Subtask" -m "Details" -p abc1234
19+```
20+
21+The parent ID accepts a 7-character short ID or a full ID.
22+
23 ### Read/Show an Issue
24
25 Display an issue with all its metadata and comments:
M
SKILL.md
+7,
-1
1@@ -104,7 +104,7 @@ bug agent read abc1234
2 - Retrieve documented plans from comments
3 - Check current status before acting
4
5-### bug agent new --title "..." [--message "..." | --stdin | --from-file <path>]
6+### bug agent new --title "..." [--message "..." | --stdin | --from-file <path>] [--parent <bugID>]
7
8 Create a new bug/issue as the agent.
9
10@@ -112,6 +112,9 @@ Create a new bug/issue as the agent.
11 # Using --message flag (simple strings)
12 bug agent new --title "CI Failure" --message "Build failed on commit abc123"
13
14+# Linking under a parent issue (short or full ID)
15+bug agent new --title "Subtask" --message "Details" --parent abc1234
16+
17 # Using --stdin (pipe content)
18 echo "Build failed on commit abc123" | bug agent new --title "CI Failure" --stdin
19
20@@ -126,6 +129,9 @@ bug agent new --title "CI Failure" --from-file /tmp/description.md
21 - `--stdin`: Read message from standard input
22 - `--from-file <path>`: Read message from a file
23
24+**Optional flags:**
25+- `--parent <bugID>`: Link the new issue under a parent issue (short or full ID)
26+
27 **Important:** `--message`, `--stdin`, and `--from-file` are **mutually exclusive**. You must choose exactly one method to provide the message content.
28
29 **Best practice for multi-line markdown content:** Use `--stdin` or `--from-file` instead of `--message`. Long multi-line strings with markdown formatting often fail when passed as shell command-line arguments due to escaping issues.
+7,
-7
1@@ -35,7 +35,7 @@ func TestAgentCommentCommand_WithFlag(t *testing.T) {
2 // Create a bug first as user
3 title := "Test Bug for Agent Comment"
4 message := "Initial bug description"
5- if err := runNew(tmpDir, title, message); err != nil {
6+ if err := runNew(tmpDir, title, message, ""); err != nil {
7 t.Fatalf("runNew failed: %v", err)
8 }
9
10@@ -121,7 +121,7 @@ func TestAgentCommentCommand_WithShortID(t *testing.T) {
11
12 // Create a bug
13 title := "Test Bug for Agent Short ID"
14- if err := runNew(tmpDir, title, ""); err != nil {
15+ if err := runNew(tmpDir, title, "", ""); err != nil {
16 t.Fatalf("runNew failed: %v", err)
17 }
18
19@@ -204,7 +204,7 @@ func TestAgentCommentCommand_MissingAgentIdentity(t *testing.T) {
20
21 // Create a bug as user
22 title := "Test Bug"
23- if err := runNew(tmpDir, title, ""); err != nil {
24+ if err := runNew(tmpDir, title, "", ""); err != nil {
25 t.Fatalf("runNew failed: %v", err)
26 }
27
28@@ -260,7 +260,7 @@ func TestAgentCommentCommand_EmptyMessageError(t *testing.T) {
29
30 // Create a bug
31 title := "Test Bug"
32- if err := runNew(tmpDir, title, ""); err != nil {
33+ if err := runNew(tmpDir, title, "", ""); err != nil {
34 t.Fatalf("runNew failed: %v", err)
35 }
36
37@@ -346,7 +346,7 @@ func TestAgentCommentCommand_MultipleComments(t *testing.T) {
38
39 // Create a bug as user
40 title := "Test Bug for Multiple Agent Comments"
41- if err := runNew(tmpDir, title, ""); err != nil {
42+ if err := runNew(tmpDir, title, "", ""); err != nil {
43 t.Fatalf("runNew failed: %v", err)
44 }
45
46@@ -433,7 +433,7 @@ func TestAgentCommentCommand_WithMarkdown(t *testing.T) {
47
48 // Create a bug
49 title := "Test Bug for Markdown"
50- if err := runNew(tmpDir, title, ""); err != nil {
51+ if err := runNew(tmpDir, title, "", ""); err != nil {
52 t.Fatalf("runNew failed: %v", err)
53 }
54
55@@ -514,7 +514,7 @@ func TestAgentCommentCommand_WithStdin(t *testing.T) {
56
57 // Create a bug
58 title := "Test Bug for Stdin Comment"
59- if err := runNew(tmpDir, title, ""); err != nil {
60+ if err := runNew(tmpDir, title, "", ""); err != nil {
61 t.Fatalf("runNew failed: %v", err)
62 }
63
+5,
-5
1@@ -35,7 +35,7 @@ func TestAgentEditCommand_EditIssue(t *testing.T) {
2 }
3
4 // Create a bug as agent
5- if err := runAgentNew(tmpDir, "Original Title", "Original description", "", false); err != nil {
6+ if err := runAgentNew(tmpDir, "Original Title", "Original description", "", "", false); err != nil {
7 t.Fatalf("runAgentNew failed: %v", err)
8 }
9
10@@ -114,7 +114,7 @@ func TestAgentEditCommand_EditComment(t *testing.T) {
11 }
12
13 // Create a bug as agent
14- if err := runAgentNew(tmpDir, "Test Bug", "Description", "", false); err != nil {
15+ if err := runAgentNew(tmpDir, "Test Bug", "Description", "", "", false); err != nil {
16 t.Fatalf("runAgentNew failed: %v", err)
17 }
18
19@@ -211,7 +211,7 @@ func TestAgentEditCommand_RequiresMessage(t *testing.T) {
20 }
21
22 // Create a bug as agent
23- if err := runAgentNew(tmpDir, "Test Bug", "Description", "", false); err != nil {
24+ if err := runAgentNew(tmpDir, "Test Bug", "Description", "", "", false); err != nil {
25 t.Fatalf("runAgentNew failed: %v", err)
26 }
27
28@@ -288,7 +288,7 @@ func TestAgentEditCommand_RequiresAtLeastOneField(t *testing.T) {
29 }
30
31 // Create a bug as agent
32- if err := runAgentNew(tmpDir, "Test Bug", "Description", "", false); err != nil {
33+ if err := runAgentNew(tmpDir, "Test Bug", "Description", "", "", false); err != nil {
34 t.Fatalf("runAgentNew failed: %v", err)
35 }
36
37@@ -348,7 +348,7 @@ func TestAgentEditCommand_OnlyTitle(t *testing.T) {
38 // Create a bug as agent
39 originalTitle := "Original Title"
40 originalDesc := "Original description"
41- if err := runAgentNew(tmpDir, originalTitle, originalDesc, "", false); err != nil {
42+ if err := runAgentNew(tmpDir, originalTitle, originalDesc, "", "", false); err != nil {
43 t.Fatalf("runAgentNew failed: %v", err)
44 }
45
+4,
-4
1@@ -34,7 +34,7 @@ func TestAgentNewCommand_WithFlags(t *testing.T) {
2 // Create a bug as agent using flags
3 title := "Agent Created Bug"
4 message := "This bug was created by an automated agent"
5- if err := runAgentNew(tmpDir, title, message, "", false); err != nil {
6+ if err := runAgentNew(tmpDir, title, message, "", "", false); err != nil {
7 t.Fatalf("runAgentNew failed: %v", err)
8 }
9
10@@ -90,7 +90,7 @@ func TestAgentNewCommand_MissingAgentIdentity(t *testing.T) {
11 }
12
13 // Try to create a bug as agent (should fail)
14- err := runAgentNew(tmpDir, "Title", "Message", "", false)
15+ err := runAgentNew(tmpDir, "Title", "Message", "", "", false)
16 if err == nil {
17 t.Error("expected error when agent identity not found, got nil")
18 }
19@@ -120,7 +120,7 @@ func TestAgentNewCommand_EmptyTitleError(t *testing.T) {
20 }
21
22 // Try with empty title
23- err := runAgentNew(tmpDir, " ", "Message", "", false)
24+ err := runAgentNew(tmpDir, " ", "Message", "", "", false)
25 if err == nil {
26 t.Error("expected error for empty title, got nil")
27 }
28@@ -150,7 +150,7 @@ func TestAgentNewCommand_EmptyMessageError(t *testing.T) {
29 }
30
31 // Try with empty message
32- err := runAgentNew(tmpDir, "Title", " ", "", false)
33+ err := runAgentNew(tmpDir, "Title", " ", "", "", false)
34 if err == nil {
35 t.Error("expected error for empty message, got nil")
36 }
+4,
-4
1@@ -31,7 +31,7 @@ func TestCommentCommand_WithFlag(t *testing.T) {
2 // Create a bug first
3 title := "Test Bug for Comment"
4 message := "Initial bug description"
5- if err := runNew(tmpDir, title, message); err != nil {
6+ if err := runNew(tmpDir, title, message, ""); err != nil {
7 t.Fatalf("runNew failed: %v", err)
8 }
9
10@@ -110,7 +110,7 @@ func TestCommentCommand_WithShortID(t *testing.T) {
11
12 // Create a bug
13 title := "Test Bug for Short ID"
14- if err := runNew(tmpDir, title, ""); err != nil {
15+ if err := runNew(tmpDir, title, "", ""); err != nil {
16 t.Fatalf("runNew failed: %v", err)
17 }
18
19@@ -214,7 +214,7 @@ func TestCommentCommand_EmptyMessageError(t *testing.T) {
20
21 // Create a bug first
22 title := "Test Bug"
23- if err := runNew(tmpDir, title, ""); err != nil {
24+ if err := runNew(tmpDir, title, "", ""); err != nil {
25 t.Fatalf("runNew failed: %v", err)
26 }
27
28@@ -296,7 +296,7 @@ func TestCommentCommand_MultipleComments(t *testing.T) {
29
30 // Create a bug
31 title := "Test Bug for Multiple Comments"
32- if err := runNew(tmpDir, title, ""); err != nil {
33+ if err := runNew(tmpDir, title, "", ""); err != nil {
34 t.Fatalf("runNew failed: %v", err)
35 }
36
+5,
-5
1@@ -31,7 +31,7 @@ func TestEditCommand_EditIssueTitle(t *testing.T) {
2 // Create a bug
3 title := "Original Title"
4 message := "Original description"
5- if err := runNew(tmpDir, title, message); err != nil {
6+ if err := runNew(tmpDir, title, message, ""); err != nil {
7 t.Fatalf("runNew failed: %v", err)
8 }
9
10@@ -106,7 +106,7 @@ func TestEditCommand_EditIssueDescription(t *testing.T) {
11 // Create a bug
12 title := "Original Title"
13 message := "Original description"
14- if err := runNew(tmpDir, title, message); err != nil {
15+ if err := runNew(tmpDir, title, message, ""); err != nil {
16 t.Fatalf("runNew failed: %v", err)
17 }
18
19@@ -179,7 +179,7 @@ func TestEditCommand_EditIssueBoth(t *testing.T) {
20 }
21
22 // Create a bug
23- if err := runNew(tmpDir, "Original Title", "Original description"); err != nil {
24+ if err := runNew(tmpDir, "Original Title", "Original description", ""); err != nil {
25 t.Fatalf("runNew failed: %v", err)
26 }
27
28@@ -248,7 +248,7 @@ func TestEditCommand_EditComment(t *testing.T) {
29 }
30
31 // Create a bug
32- if err := runNew(tmpDir, "Test Bug", "Description"); err != nil {
33+ if err := runNew(tmpDir, "Test Bug", "Description", ""); err != nil {
34 t.Fatalf("runNew failed: %v", err)
35 }
36
37@@ -366,7 +366,7 @@ func TestEditCommand_WithShortID(t *testing.T) {
38 }
39
40 // Create a bug
41- if err := runNew(tmpDir, "Original Title", "Original description"); err != nil {
42+ if err := runNew(tmpDir, "Original Title", "Original description", ""); err != nil {
43 t.Fatalf("runNew failed: %v", err)
44 }
45
M
main.go
+316,
-44
1@@ -13,6 +13,7 @@ import (
2
3 "github.com/charmbracelet/lipgloss"
4 "github.com/dustin/go-humanize"
5+ "github.com/git-bug/git-bug/cache"
6 "github.com/git-bug/git-bug/entities/bug"
7 "github.com/git-bug/git-bug/entities/identity"
8 "github.com/git-bug/git-bug/entity"
9@@ -28,6 +29,7 @@ type BugIssue struct {
10 Labels []string
11 CreatedAt time.Time
12 Status string
13+ ParentID string // Full ID of parent bug
14 }
15
16 // LoadBugs loads all issues from the git-bug repository
17@@ -36,6 +38,19 @@ func LoadBugs(repoPath string) ([]BugIssue, error) {
18 if err != nil {
19 return nil, fmt.Errorf("failed to open repository: %w", err)
20 }
21+ defer repo.Close()
22+
23+ // Create cache to access bugs with metadata support
24+ // Use NewRepoCache and wait for build events to ensure cache is fully built
25+ cacheInstance, events := cache.NewRepoCache(repo)
26+ defer cacheInstance.Close()
27+
28+ // Wait for cache to be fully built
29+ for ev := range events {
30+ if ev.Err != nil {
31+ return nil, fmt.Errorf("failed to build bug cache: %w", ev.Err)
32+ }
33+ }
34
35 // Generate short IDs for all issues
36 gen := NewShortIDGenerator(repoPath)
37@@ -46,15 +61,20 @@ func LoadBugs(repoPath string) ([]BugIssue, error) {
38
39 var issues []BugIssue
40
41- for streamedBug := range bug.ReadAll(repo) {
42- if streamedBug.Err != nil {
43+ // Use cache to list bugs with metadata
44+ bugCache := cacheInstance.Bugs()
45+ ids := bugCache.AllIds()
46+
47+ for _, id := range ids {
48+ // Use Resolve to get BugCache which has Snapshot() with GetCreateMetadata
49+ b, err := bugCache.Resolve(id)
50+ if err != nil {
51 continue // Skip errors, process what we can
52 }
53
54- b := streamedBug.Entity
55- snap := b.Compile()
56+ snap := b.Snapshot()
57
58- fullID := b.Id().String()
59+ fullID := id.String()
60 shortID, _ := shortIDMap.GetShortID(fullID)
61
62 labels := make([]string, len(snap.Labels))
63@@ -62,6 +82,12 @@ func LoadBugs(repoPath string) ([]BugIssue, error) {
64 labels[i] = string(label)
65 }
66
67+ // Read parent metadata from CreateMetadata
68+ parentID := ""
69+ if parent, ok := snap.GetCreateMetadata("parent"); ok {
70+ parentID = parent
71+ }
72+
73 issues = append(issues, BugIssue{
74 FullID: fullID,
75 ShortID: shortID,
76@@ -69,6 +95,7 @@ func LoadBugs(repoPath string) ([]BugIssue, error) {
77 Labels: labels,
78 CreatedAt: snap.CreateTime,
79 Status: snap.Status.String(),
80+ ParentID: parentID,
81 })
82 }
83
84@@ -237,7 +264,7 @@ func ApplySort(issues []BugIssue, spec *SortSpec) {
85 case "id":
86 less = issues[i].FullID < issues[j].FullID
87 case "age":
88- less = issues[i].CreatedAt.After(issues[j].CreatedAt) // Newer first
89+ less = issues[i].CreatedAt.Before(issues[j].CreatedAt) // Older first (ascending)
90 }
91
92 if spec.Direction == "desc" {
93@@ -378,6 +405,185 @@ func printTable(issues []BugIssue) error {
94 return nil
95 }
96
97+// TreeNode represents a node in the issue tree
98+type TreeNode struct {
99+ Issue BugIssue
100+ Children []*TreeNode
101+}
102+
103+// buildIssueTree builds a tree structure from flat issues
104+// Returns root nodes (issues with no parent or parent not in the list)
105+func buildIssueTree(issues []BugIssue) []*TreeNode {
106+ // Map of full ID to TreeNode
107+ nodeMap := make(map[string]*TreeNode)
108+
109+ // Create nodes for all issues
110+ for i := range issues {
111+ nodeMap[issues[i].FullID] = &TreeNode{Issue: issues[i]}
112+ }
113+
114+ // Link children to parents
115+ var roots []*TreeNode
116+ for _, node := range nodeMap {
117+ if node.Issue.ParentID != "" {
118+ if parent, ok := nodeMap[node.Issue.ParentID]; ok {
119+ parent.Children = append(parent.Children, node)
120+ } else {
121+ // Parent not in list, treat as root
122+ roots = append(roots, node)
123+ }
124+ } else {
125+ // No parent, treat as root
126+ roots = append(roots, node)
127+ }
128+ }
129+
130+ // Sort roots by age descending (newest first)
131+ sort.Slice(roots, func(i, j int) bool {
132+ return roots[i].Issue.CreatedAt.After(roots[j].Issue.CreatedAt)
133+ })
134+
135+ // Sort children of each node by age descending
136+ var sortChildren func([]*TreeNode)
137+ sortChildren = func(nodes []*TreeNode) {
138+ sort.Slice(nodes, func(i, j int) bool {
139+ return nodes[i].Issue.CreatedAt.After(nodes[j].Issue.CreatedAt)
140+ })
141+ for _, n := range nodes {
142+ if len(n.Children) > 0 {
143+ sortChildren(n.Children)
144+ }
145+ }
146+ }
147+ sortChildren(roots)
148+
149+ return roots
150+}
151+
152+// printTreeTable prints issues as a tree with indentation markers
153+func printTreeTable(roots []*TreeNode) error {
154+ // First pass: calculate column widths
155+ const minColSpacing = 2
156+ const maxSummaryLen = 70
157+
158+ idWidth := len("ID")
159+ summaryWidth := len("Summary")
160+ labelsWidth := len("Labels")
161+ ageWidth := len("Age")
162+
163+ type rowData struct {
164+ id string
165+ summary string
166+ labels string
167+ age string
168+ prefix string // Tree prefix for indentation
169+ }
170+
171+ var allRows []rowData
172+
173+ // Recursive function to collect all rows with prefixes.
174+ // Roots (isRoot) have no prefix; their children get tree markers,
175+ // and deeper levels carry vertical guides from their ancestors.
176+ var collectRows func(nodes []*TreeNode, prefix string, isRoot bool)
177+ collectRows = func(nodes []*TreeNode, prefix string, isRoot bool) {
178+ for i, node := range nodes {
179+ isLast := i == len(nodes)-1
180+ var nodePrefix string
181+ if isRoot {
182+ nodePrefix = ""
183+ } else if isLast {
184+ nodePrefix = prefix + "└─ "
185+ } else {
186+ nodePrefix = prefix + "├─ "
187+ }
188+
189+ // Format ID (with ANSI codes)
190+ id := formatID(node.Issue.FullID, node.Issue.ShortID)
191+ idVisualLen := len(nodePrefix) + len(stripANSI(id))
192+ if idVisualLen > idWidth {
193+ idWidth = idVisualLen
194+ }
195+
196+ // Format summary (truncate at 70 chars)
197+ summary := node.Issue.Title
198+ if len(summary) > maxSummaryLen {
199+ summary = summary[:maxSummaryLen-3] + "..."
200+ }
201+ if len(summary) > summaryWidth {
202+ summaryWidth = len(summary)
203+ }
204+
205+ // Format labels
206+ labels := strings.Join(node.Issue.Labels, ", ")
207+ if labels == "" {
208+ labels = "-"
209+ }
210+ if len(labels) > labelsWidth {
211+ labelsWidth = len(labels)
212+ }
213+
214+ // Format age
215+ age := humanize.Time(node.Issue.CreatedAt)
216+ if len(age) > ageWidth {
217+ ageWidth = len(age)
218+ }
219+
220+ allRows = append(allRows, rowData{
221+ id: id,
222+ summary: summary,
223+ labels: labels,
224+ age: age,
225+ prefix: nodePrefix,
226+ })
227+
228+ // Recurse into children
229+ if len(node.Children) > 0 {
230+ var childPrefix string
231+ if isRoot {
232+ childPrefix = ""
233+ } else if isLast {
234+ childPrefix = prefix + " "
235+ } else {
236+ childPrefix = prefix + "│ "
237+ }
238+ collectRows(node.Children, childPrefix, false)
239+ }
240+ }
241+ }
242+
243+ collectRows(roots, "", true)
244+
245+ // Print header
246+ fmt.Printf("%s%s%s%s%s%s%s\n",
247+ padRight("ID", idWidth),
248+ strings.Repeat(" ", minColSpacing),
249+ padRight("Summary", summaryWidth),
250+ strings.Repeat(" ", minColSpacing),
251+ padRight("Labels", labelsWidth),
252+ strings.Repeat(" ", minColSpacing),
253+ padRight("Age", ageWidth),
254+ )
255+
256+ // Print separator
257+ totalWidth := idWidth + minColSpacing + summaryWidth + minColSpacing + labelsWidth + minColSpacing + ageWidth
258+ fmt.Println(strings.Repeat("-", totalWidth))
259+
260+ // Print rows
261+ for _, row := range allRows {
262+ fmt.Printf("%s%s%s%s%s%s%s\n",
263+ padRight(row.prefix+row.id, idWidth),
264+ strings.Repeat(" ", minColSpacing),
265+ padRight(row.summary, summaryWidth),
266+ strings.Repeat(" ", minColSpacing),
267+ padRight(row.labels, labelsWidth),
268+ strings.Repeat(" ", minColSpacing),
269+ padRight(row.age, ageWidth),
270+ )
271+ }
272+
273+ return nil
274+}
275+
276 // padRight pads a string to the specified width, accounting for ANSI codes
277 func padRight(s string, width int) string {
278 visualLen := len(stripANSI(s))
279@@ -1152,7 +1358,7 @@ func parseCommentContent(content string) (string, error) {
280
281 // runNew creates a new bug/issue in the repository
282 // If title is empty, opens editor for interactive input
283-func runNew(repoPath, title, message string) error {
284+func runNew(repoPath, title, message, parentID string) error {
285 // If no title provided via flags, open editor
286 if title == "" && message == "" {
287 content, err := launchEditor("")
288@@ -1194,16 +1400,29 @@ func runNew(repoPath, title, message string) error {
289 return fmt.Errorf("failed to get user identity: %w\n\nRun 'bug init' first to set up identities", err)
290 }
291
292- // Create the bug
293- unixTime := time.Now().Unix()
294- newBug, _, err := bug.Create(author, unixTime, title, message, nil, nil)
295+ // Prepare metadata for parent if provided
296+ var metadata map[string]string
297+ if parentID != "" {
298+ // Resolve parent ID to full ID
299+ parentBug, err := resolveBugID(repoPath, parentID)
300+ if err != nil {
301+ return fmt.Errorf("failed to resolve parent bug ID %q: %w", parentID, err)
302+ }
303+ metadata = map[string]string{"parent": parentBug.Id().String()}
304+ }
305+
306+ // Create cache to use NewRaw which supports metadata
307+ cacheInstance, err := cache.NewRepoCacheNoEvents(repo)
308 if err != nil {
309- return fmt.Errorf("failed to create bug: %w", err)
310+ return fmt.Errorf("failed to create cache: %w", err)
311 }
312+ defer cacheInstance.Close()
313
314- // Commit the bug to the repository
315- if err := newBug.Commit(repo); err != nil {
316- return fmt.Errorf("failed to commit bug: %w", err)
317+ // Create the bug with metadata support
318+ unixTime := time.Now().Unix()
319+ newBug, _, err := cacheInstance.Bugs().NewRaw(author, unixTime, title, message, nil, metadata)
320+ if err != nil {
321+ return fmt.Errorf("failed to create bug: %w", err)
322 }
323
324 // Get the bug ID and display first 7 characters (like bug ls table)
325@@ -1321,21 +1540,23 @@ func runInitWithReader(repoPath string, reader io.Reader) error {
326 }
327
328 var (
329- repoPath string
330- filterFlag string
331- sortFlag string
332- newTitle string
333- newMessage string
334- agentTitle string
335- agentMessage string
336- commentMessage string
337- commentBugID string
338- agentCommentMsg string
339- editTitle string
340- editMessage string
341- agentEditTitle string
342- agentEditMsg string
343- statusFlag string
344+ repoPath string
345+ filterFlag string
346+ sortFlag string
347+ newTitle string
348+ newMessage string
349+ newParentID string
350+ agentTitle string
351+ agentMessage string
352+ agentNewParentID string
353+ commentMessage string
354+ commentBugID string
355+ agentCommentMsg string
356+ editTitle string
357+ editMessage string
358+ agentEditTitle string
359+ agentEditMsg string
360+ statusFlag string
361
362 // Agent command stdin/from-file flags
363 agentNewStdinFlag bool
364@@ -1359,6 +1580,11 @@ var listCmd = &cobra.Command{
365 Short: "List all issues in the repository",
366 Long: `Display a table of all git-bug issues with their ID, summary, labels, and age.
367
368+Issues linked to a parent via --parent are nested under it with tree
369+markers (├─, └─). Parents sort newest first, with each parent's
370+children nested below it newest first. Passing --sort forces a flat
371+table with that sort order instead.
372+
373 Filtering:
374 --filter label:bug Show only issues with label "bug"
375 --filter age:<10d Show only issues newer than 10 days
376@@ -1407,18 +1633,42 @@ Status Filtering:
377 issues = filtered
378 }
379
380- // Parse and apply sort
381+ if len(issues) == 0 {
382+ fmt.Println("No Issues")
383+ return nil
384+ }
385+
386+ // Check if any issues have parent relationships
387+ hasParentRelations := false
388+ for _, issue := range issues {
389+ if issue.ParentID != "" {
390+ hasParentRelations = true
391+ break
392+ }
393+ }
394+
395+ // Parse sort flag
396 sortSpec, err := ParseSort(sortFlag)
397 if err != nil {
398 return err
399 }
400- ApplySort(issues, sortSpec)
401
402- if len(issues) == 0 {
403- fmt.Println("No Issues")
404- return nil
405+ // If user specified a sort flag, use flat table with that sort
406+ // Otherwise, if there are parent relations, use tree display
407+ // Otherwise, use flat table with default sort (age:desc)
408+ if sortFlag != "" {
409+ ApplySort(issues, sortSpec)
410+ return printTable(issues)
411+ }
412+
413+ if hasParentRelations {
414+ // Build and print tree
415+ roots := buildIssueTree(issues)
416+ return printTreeTable(roots)
417 }
418
419+ // Default: flat table with age:desc (already the default in ApplySort)
420+ ApplySort(issues, sortSpec)
421 return printTable(issues)
422 },
423 }
424@@ -1464,13 +1714,17 @@ You can provide the title and message via flags:
425 bug new --title "Bug title" --message "Detailed description"
426 bug new -t "Bug title" -m "Detailed description"
427
428+To link the new issue under a parent issue:
429+ bug new --title "Subtask" --message "Details" --parent abc1234
430+ bug new -t "Subtask" -m "Details" -p abc1234
431+
432 Or launch an editor to write them interactively:
433 bug new
434
435 The editor will open with a template. The first non-empty line becomes the title,
436 and the rest becomes the description.`,
437 RunE: func(cmd *cobra.Command, args []string) error {
438- return runNew(repoPath, newTitle, newMessage)
439+ return runNew(repoPath, newTitle, newMessage, newParentID)
440 },
441 }
442
443@@ -1494,11 +1748,14 @@ var agentNewCmd = &cobra.Command{
444 This command is non-interactive and requires both --title and --message flags.
445 It uses the agent identity created during 'bug init'.
446
447+To link the new issue under a parent issue:
448+ bug agent new --title "Subtask" --message "Details" --parent abc1234
449+
450 Example:
451 bug agent new --title "CI Failure" --message "Build failed on commit abc123"
452 bug agent new -t "Bug found" -m "Automated scan detected issue"`,
453 RunE: func(cmd *cobra.Command, args []string) error {
454- return runAgentNew(repoPath, agentTitle, agentMessage, agentNewFromFile, agentNewStdinFlag)
455+ return runAgentNew(repoPath, agentTitle, agentMessage, agentNewParentID, agentNewFromFile, agentNewStdinFlag)
456 },
457 }
458
459@@ -1745,10 +2002,12 @@ func init() {
460
461 newCmd.Flags().StringVarP(&newTitle, "title", "t", "", "issue title")
462 newCmd.Flags().StringVarP(&newMessage, "message", "m", "", "issue description/message")
463+ newCmd.Flags().StringVarP(&newParentID, "parent", "p", "", "parent bug ID (7-char short or full ID)")
464
465 // Agent new command flags - both required, no shorthand to force explicit usage
466 agentNewCmd.Flags().StringVar(&agentTitle, "title", "", "issue title (required)")
467 agentNewCmd.Flags().StringVar(&agentMessage, "message", "", "issue description")
468+ agentNewCmd.Flags().StringVar(&agentNewParentID, "parent", "", "parent bug ID (7-char short or full ID)")
469 agentNewCmd.Flags().BoolVar(&agentNewStdinFlag, "stdin", false, "read message from stdin")
470 agentNewCmd.Flags().StringVar(&agentNewFromFile, "from-file", "", "read message from file")
471 agentNewCmd.MarkFlagRequired("title")
472@@ -1859,7 +2118,7 @@ Run 'bug agent <command> --help' for more information.`)
473
474 // runAgentNew creates a new bug/issue using the agent identity
475 // Both title and message are required - this is non-interactive
476-func runAgentNew(repoPath, title, message string, fromFile string, useStdin bool) error {
477+func runAgentNew(repoPath, title, message, parentID string, fromFile string, useStdin bool) error {
478 // Validate title (still required)
479 if strings.TrimSpace(title) == "" {
480 return fmt.Errorf("title cannot be empty")
481@@ -1884,16 +2143,29 @@ func runAgentNew(repoPath, title, message string, fromFile string, useStdin bool
482 return err
483 }
484
485- // Create the bug
486- unixTime := time.Now().Unix()
487- newBug, _, err := bug.Create(author, unixTime, title, messageContent, nil, nil)
488+ // Prepare metadata for parent if provided
489+ var metadata map[string]string
490+ if parentID != "" {
491+ // Resolve parent ID to full ID
492+ parentBug, err := resolveBugID(repoPath, parentID)
493+ if err != nil {
494+ return fmt.Errorf("failed to resolve parent bug ID %q: %w", parentID, err)
495+ }
496+ metadata = map[string]string{"parent": parentBug.Id().String()}
497+ }
498+
499+ // Create cache to use NewRaw which supports metadata
500+ cacheInstance, err := cache.NewRepoCacheNoEvents(repo)
501 if err != nil {
502- return fmt.Errorf("failed to create bug: %w", err)
503+ return fmt.Errorf("failed to create cache: %w", err)
504 }
505+ defer cacheInstance.Close()
506
507- // Commit the bug to persist it
508- if err := newBug.Commit(repo); err != nil {
509- return fmt.Errorf("failed to commit bug: %w", err)
510+ // Create the bug with metadata support
511+ unixTime := time.Now().Unix()
512+ newBug, _, err := cacheInstance.Bugs().NewRaw(author, unixTime, title, messageContent, nil, metadata)
513+ if err != nil {
514+ return fmt.Errorf("failed to create bug: %w", err)
515 }
516
517 // Get the bug ID and display first 7 characters (like bug ls table)
+4,
-4
1@@ -30,7 +30,7 @@ func TestNewCommand_WithFlags(t *testing.T) {
2 // Create a bug using flags
3 title := "Test Bug Title"
4 message := "Test bug description"
5- if err := runNew(tmpDir, title, message); err != nil {
6+ if err := runNew(tmpDir, title, message, ""); err != nil {
7 t.Fatalf("runNew failed: %v", err)
8 }
9
10@@ -84,7 +84,7 @@ func TestNewCommand_TitleOnly(t *testing.T) {
11
12 // Create a bug with title only
13 title := "Title Only Bug"
14- if err := runNew(tmpDir, title, ""); err != nil {
15+ if err := runNew(tmpDir, title, "", ""); err != nil {
16 t.Fatalf("runNew failed: %v", err)
17 }
18
19@@ -130,7 +130,7 @@ func TestNewCommand_NoIdentityError(t *testing.T) {
20 }
21
22 // Try to create a bug without identity
23- err := runNew(tmpDir, "Test Title", "Test message")
24+ err := runNew(tmpDir, "Test Title", "Test message", "")
25 if err == nil {
26 t.Error("expected error when no identity is set, got nil")
27 }
28@@ -158,7 +158,7 @@ func TestNewCommand_EmptyTitleError(t *testing.T) {
29
30 // Try to create a bug with empty title (this would normally trigger editor)
31 // For direct testing, we test the validation after editor parsing
32- err := runNew(tmpDir, " ", "") // Whitespace-only title
33+ err := runNew(tmpDir, " ", "", "") // Whitespace-only title
34 if err == nil {
35 t.Error("expected error for empty title, got nil")
36 }
+103,
-0
1@@ -0,0 +1,103 @@
2+//go:build integration
3+
4+package main
5+
6+import (
7+ "os/exec"
8+ "strings"
9+ "testing"
10+)
11+
12+// setupParentTestRepo initializes a git repo with user and agent identities
13+func setupParentTestRepo(t *testing.T) string {
14+ t.Helper()
15+ tmpDir := t.TempDir()
16+
17+ initCmd := exec.Command("git", "init")
18+ initCmd.Dir = tmpDir
19+ if err := initCmd.Run(); err != nil {
20+ t.Fatalf("failed to init git repo: %v", err)
21+ }
22+
23+ if err := createIdentity(tmpDir, "Test User", "[email protected]", true); err != nil {
24+ t.Fatalf("failed to create user identity: %v", err)
25+ }
26+ if err := createIdentity(tmpDir, "agent", "", false); err != nil {
27+ t.Fatalf("failed to create agent identity: %v", err)
28+ }
29+
30+ return tmpDir
31+}
32+
33+// findIssueByTitle returns the BugIssue with the given title
34+func findIssueByTitle(t *testing.T, repoPath, title string) BugIssue {
35+ t.Helper()
36+ issues, err := LoadBugs(repoPath)
37+ if err != nil {
38+ t.Fatalf("LoadBugs failed: %v", err)
39+ }
40+ for _, issue := range issues {
41+ if issue.Title == title {
42+ return issue
43+ }
44+ }
45+ t.Fatalf("bug with title %q not found", title)
46+ return BugIssue{}
47+}
48+
49+// TestNewCommand_WithParent creates a child bug linked to a parent via short ID
50+func TestNewCommand_WithParent(t *testing.T) {
51+ tmpDir := setupParentTestRepo(t)
52+
53+ parentTitle := "Parent Bug"
54+ if err := runNew(tmpDir, parentTitle, "Parent description", ""); err != nil {
55+ t.Fatalf("runNew parent failed: %v", err)
56+ }
57+ parent := findIssueByTitle(t, tmpDir, parentTitle)
58+ if parent.ShortID == "" {
59+ t.Fatal("parent has empty short ID")
60+ }
61+
62+ childTitle := "Child Bug"
63+ if err := runNew(tmpDir, childTitle, "Child description", parent.ShortID); err != nil {
64+ t.Fatalf("runNew child failed: %v", err)
65+ }
66+
67+ child := findIssueByTitle(t, tmpDir, childTitle)
68+ if child.ParentID != parent.FullID {
69+ t.Errorf("child ParentID = %q, want parent full ID %q", child.ParentID, parent.FullID)
70+ }
71+}
72+
73+// TestAgentNewCommand_WithParent creates a child bug as agent linked to a parent
74+func TestAgentNewCommand_WithParent(t *testing.T) {
75+ tmpDir := setupParentTestRepo(t)
76+
77+ parentTitle := "Agent Parent Bug"
78+ if err := runAgentNew(tmpDir, parentTitle, "Parent description", "", "", false); err != nil {
79+ t.Fatalf("runAgentNew parent failed: %v", err)
80+ }
81+ parent := findIssueByTitle(t, tmpDir, parentTitle)
82+
83+ childTitle := "Agent Child Bug"
84+ if err := runAgentNew(tmpDir, childTitle, "Child description", parent.ShortID, "", false); err != nil {
85+ t.Fatalf("runAgentNew child failed: %v", err)
86+ }
87+
88+ child := findIssueByTitle(t, tmpDir, childTitle)
89+ if child.ParentID != parent.FullID {
90+ t.Errorf("child ParentID = %q, want parent full ID %q", child.ParentID, parent.FullID)
91+ }
92+}
93+
94+// TestNewCommand_InvalidParentError tests error for unknown parent ID
95+func TestNewCommand_InvalidParentError(t *testing.T) {
96+ tmpDir := setupParentTestRepo(t)
97+
98+ err := runNew(tmpDir, "Child Bug", "Child description", "deadbee")
99+ if err == nil {
100+ t.Error("expected error for unknown parent ID, got nil")
101+ } else if !strings.Contains(err.Error(), "deadbee") {
102+ t.Errorf("expected error to mention parent ID, got: %v", err)
103+ }
104+}
+1,
-1
1@@ -34,7 +34,7 @@ func TestReadCommand_Integration(t *testing.T) {
2 // Create a bug with a title and description
3 title := "Test Issue for Read Command"
4 message := "This is a test description for the read command."
5- if err := runNew(tmpDir, title, message); err != nil {
6+ if err := runNew(tmpDir, title, message, ""); err != nil {
7 t.Fatalf("Failed to create bug: %v", err)
8 }
9
+138,
-0
1@@ -0,0 +1,138 @@
2+package main
3+
4+import (
5+ "io"
6+ "os"
7+ "strings"
8+ "testing"
9+ "time"
10+)
11+
12+func TestApplySort_AgeDescNewestFirst(t *testing.T) {
13+ now := time.Now()
14+ issues := []BugIssue{
15+ {FullID: "older", CreatedAt: now.Add(-2 * time.Hour)},
16+ {FullID: "newer", CreatedAt: now.Add(-1 * time.Hour)},
17+ }
18+
19+ ApplySort(issues, &SortSpec{Field: "age", Direction: "desc"})
20+
21+ if issues[0].FullID != "newer" {
22+ t.Errorf("age:desc first = %q, want %q (newest first)", issues[0].FullID, "newer")
23+ }
24+}
25+
26+func TestApplySort_AgeAscOldestFirst(t *testing.T) {
27+ now := time.Now()
28+ issues := []BugIssue{
29+ {FullID: "newer", CreatedAt: now.Add(-1 * time.Hour)},
30+ {FullID: "older", CreatedAt: now.Add(-2 * time.Hour)},
31+ }
32+
33+ ApplySort(issues, &SortSpec{Field: "age", Direction: "asc"})
34+
35+ if issues[0].FullID != "older" {
36+ t.Errorf("age:asc first = %q, want %q (oldest first)", issues[0].FullID, "older")
37+ }
38+}
39+
40+func TestBuildIssueTree_NestsChildrenUnderParent(t *testing.T) {
41+ now := time.Now()
42+ issues := []BugIssue{
43+ {FullID: "parent-full-id", ShortID: "parent", Title: "Parent", CreatedAt: now.Add(-2 * time.Hour)},
44+ {FullID: "child1-full-id", ShortID: "child1", Title: "Child 1", CreatedAt: now.Add(-1 * time.Hour), ParentID: "parent-full-id"},
45+ {FullID: "child2-full-id", ShortID: "child2", Title: "Child 2", CreatedAt: now.Add(-30 * time.Minute), ParentID: "parent-full-id"},
46+ }
47+
48+ roots := buildIssueTree(issues)
49+
50+ if len(roots) != 1 {
51+ t.Fatalf("roots = %d, want 1", len(roots))
52+ }
53+ if len(roots[0].Children) != 2 {
54+ t.Fatalf("children = %d, want 2", len(roots[0].Children))
55+ }
56+}
57+
58+func TestBuildIssueTree_SortsRootsAndChildrenNewestFirst(t *testing.T) {
59+ now := time.Now()
60+ issues := []BugIssue{
61+ {FullID: "old-parent", ShortID: "old", Title: "Old parent", CreatedAt: now.Add(-48 * time.Hour)},
62+ {FullID: "new-parent", ShortID: "new", Title: "New parent", CreatedAt: now.Add(-24 * time.Hour)},
63+ {FullID: "old-child", ShortID: "oldc", Title: "Old child", CreatedAt: now.Add(-2 * time.Hour), ParentID: "new-parent"},
64+ {FullID: "new-child", ShortID: "newc", Title: "New child", CreatedAt: now.Add(-1 * time.Hour), ParentID: "new-parent"},
65+ }
66+
67+ roots := buildIssueTree(issues)
68+
69+ if len(roots) != 2 {
70+ t.Fatalf("roots = %d, want 2", len(roots))
71+ }
72+ if roots[0].Issue.FullID != "new-parent" {
73+ t.Errorf("first root = %q, want %q (newest parent first)", roots[0].Issue.FullID, "new-parent")
74+ }
75+ if len(roots[0].Children) != 2 {
76+ t.Fatalf("children = %d, want 2", len(roots[0].Children))
77+ }
78+ if roots[0].Children[0].Issue.FullID != "new-child" {
79+ t.Errorf("first child = %q, want %q (newest child first)", roots[0].Children[0].Issue.FullID, "new-child")
80+ }
81+}
82+
83+func TestBuildIssueTree_OrphanParentTreatedAsRoot(t *testing.T) {
84+ now := time.Now()
85+ issues := []BugIssue{
86+ {FullID: "orphan", ShortID: "orphan", Title: "Orphan", CreatedAt: now, ParentID: "missing-parent"},
87+ }
88+
89+ roots := buildIssueTree(issues)
90+
91+ if len(roots) != 1 {
92+ t.Fatalf("roots = %d, want 1 (orphan treated as root)", len(roots))
93+ }
94+}
95+
96+func captureTreeOutput(t *testing.T, roots []*TreeNode) string {
97+ t.Helper()
98+
99+ old := os.Stdout
100+ r, w, err := os.Pipe()
101+ if err != nil {
102+ t.Fatalf("os.Pipe failed: %v", err)
103+ }
104+ os.Stdout = w
105+
106+ printErr := printTreeTable(roots)
107+
108+ _ = w.Close()
109+ os.Stdout = old
110+
111+ if printErr != nil {
112+ t.Fatalf("printTreeTable failed: %v", printErr)
113+ }
114+
115+ out, err := io.ReadAll(r)
116+ if err != nil {
117+ t.Fatalf("reading captured output failed: %v", err)
118+ }
119+ return string(out)
120+}
121+
122+func TestPrintTreeTable_ShowsChildMarkers(t *testing.T) {
123+ now := time.Now()
124+ issues := []BugIssue{
125+ {FullID: "parent-full-id", ShortID: "parent", Title: "Parent", CreatedAt: now.Add(-2 * time.Hour)},
126+ {FullID: "child1-full-id", ShortID: "child1", Title: "Child 1", CreatedAt: now.Add(-1 * time.Hour), ParentID: "parent-full-id"},
127+ {FullID: "child2-full-id", ShortID: "child2", Title: "Child 2", CreatedAt: now.Add(-30 * time.Minute), ParentID: "parent-full-id"},
128+ }
129+
130+ roots := buildIssueTree(issues)
131+ out := captureTreeOutput(t, roots)
132+
133+ if !strings.Contains(out, "├─ ") {
134+ t.Errorf("output missing non-last child marker %q\noutput:\n%s", "├─ ", out)
135+ }
136+ if !strings.Contains(out, "└─ ") {
137+ t.Errorf("output missing last child marker %q\noutput:\n%s", "└─ ", out)
138+ }
139+}
+0,
-0
+0,
-0