From 4669de762514b5f49c4a80048bf332e5ae9dbe60 Mon Sep 17 00:00:00 2001 From: Alberto Garcia Illera Date: Sat, 24 Jan 2026 17:12:26 -0800 Subject: [PATCH] feat(export): add --id and --parent filters (#1292) Add two new filter flags to the export command: - --id: Filter by specific issue IDs (comma-separated) - --parent: Filter by parent issue ID (shows children) Also fix safety checks (empty DB, staleness) to skip when filters are active, since filtered exports intentionally produce subsets. Co-authored-by: Claude Opus 4.5 --- cmd/bd/export.go | 41 +++++- cmd/bd/export_test.go | 319 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 358 insertions(+), 2 deletions(-) diff --git a/cmd/bd/export.go b/cmd/bd/export.go index 972062ab..16520c62 100644 --- a/cmd/bd/export.go +++ b/cmd/bd/export.go @@ -150,6 +150,8 @@ Examples: createdBefore, _ := cmd.Flags().GetString("created-before") updatedAfter, _ := cmd.Flags().GetString("updated-after") updatedBefore, _ := cmd.Flags().GetString("updated-before") + idFilter, _ := cmd.Flags().GetString("id") + parentID, _ := cmd.Flags().GetString("parent") debug.Logf("Debug: export flags - output=%q, force=%v\n", output, force) @@ -225,6 +227,15 @@ Examples: if len(labelsAny) > 0 { filter.LabelsAny = labelsAny } + if idFilter != "" { + ids := util.NormalizeLabels(strings.Split(idFilter, ",")) + if len(ids) > 0 { + filter.IDs = ids + } + } + if parentID != "" { + filter.ParentID = &parentID + } // Priority exact match (use Changed() to properly handle P0) if cmd.Flags().Changed("priority") { @@ -297,8 +308,27 @@ Examples: os.Exit(1) } + // Detect if any substantive filters are active (partial export) + // Safety checks should be skipped for filtered exports since the user + // intentionally wants a subset of issues, not the full database + isFilteredExport := filter.Status != nil || + filter.Assignee != nil || + filter.IssueType != nil || + len(filter.Labels) > 0 || + len(filter.LabelsAny) > 0 || + len(filter.IDs) > 0 || + filter.ParentID != nil || + filter.Priority != nil || + filter.PriorityMin != nil || + filter.PriorityMax != nil || + filter.CreatedAfter != nil || + filter.CreatedBefore != nil || + filter.UpdatedAfter != nil || + filter.UpdatedBefore != nil + // Safety check: prevent exporting empty database over non-empty JSONL - if len(issues) == 0 && output != "" && !force { + // Skip this check for filtered exports - the user intentionally wants a subset + if len(issues) == 0 && output != "" && !force && !isFilteredExport { existingCount, err := countIssuesInJSONL(output) if err != nil { // If we can't read the file, it might not exist yet, which is fine @@ -317,7 +347,8 @@ Examples: } // Safety check: prevent exporting stale database that would lose issues - if output != "" && !force { + // Skip this check for filtered exports - the user intentionally wants a subset + if output != "" && !force && !isFilteredExport { debug.Logf("Debug: checking staleness - output=%s, force=%v\n", output, force) // Read existing JSONL to get issue IDs @@ -616,5 +647,11 @@ func init() { exportCmd.Flags().String("updated-after", "", "Filter issues updated after date (YYYY-MM-DD or RFC3339)") exportCmd.Flags().String("updated-before", "", "Filter issues updated before date (YYYY-MM-DD or RFC3339)") + // ID filter + exportCmd.Flags().String("id", "", "Filter by specific issue IDs (comma-separated, e.g., bd-1,bd-5,bd-10)") + + // Parent filter + exportCmd.Flags().String("parent", "", "Filter by parent issue ID (shows children of specified issue)") + rootCmd.AddCommand(exportCmd) } diff --git a/cmd/bd/export_test.go b/cmd/bd/export_test.go index 1ddb40f6..f914c5fe 100644 --- a/cmd/bd/export_test.go +++ b/cmd/bd/export_test.go @@ -331,6 +331,325 @@ func TestExportCommand(t *testing.T) { } }) + t.Run("export with id filter", func(t *testing.T) { + exportPath := filepath.Join(tmpDir, "export_id_filter.jsonl") + + // Clear export hashes to force re-export + if err := s.ClearAllExportHashes(ctx); err != nil { + t.Fatalf("Failed to clear export hashes: %v", err) + } + + store = s + dbPath = testDB + rootCtx = ctx + defer func() { rootCtx = nil }() + + // Filter by first issue's ID only + exportCmd.Flags().Set("output", exportPath) + exportCmd.Flags().Set("id", issues[0].ID) + defer exportCmd.Flags().Set("id", "") // Reset flag after test + exportCmd.Run(exportCmd, []string{}) + + // Verify only one issue was exported + file, err := os.Open(exportPath) + if err != nil { + t.Fatalf("Failed to open export file: %v", err) + } + defer file.Close() + + scanner := bufio.NewScanner(file) + lineCount := 0 + var exportedIssue types.Issue + for scanner.Scan() { + lineCount++ + if err := json.Unmarshal(scanner.Bytes(), &exportedIssue); err != nil { + t.Fatalf("Failed to parse JSONL line %d: %v", lineCount, err) + } + } + + if lineCount != 1 { + t.Errorf("Expected 1 issue in export with ID filter, got %d", lineCount) + } + if exportedIssue.ID != issues[0].ID { + t.Errorf("Expected issue ID %s, got %s", issues[0].ID, exportedIssue.ID) + } + }) + + t.Run("export with multiple id filter", func(t *testing.T) { + exportPath := filepath.Join(tmpDir, "export_multi_id_filter.jsonl") + + // Clear export hashes to force re-export + if err := s.ClearAllExportHashes(ctx); err != nil { + t.Fatalf("Failed to clear export hashes: %v", err) + } + + store = s + dbPath = testDB + rootCtx = ctx + defer func() { rootCtx = nil }() + + // Filter by both issue IDs (comma-separated) + exportCmd.Flags().Set("output", exportPath) + exportCmd.Flags().Set("id", issues[0].ID+","+issues[1].ID) + defer exportCmd.Flags().Set("id", "") // Reset flag after test + exportCmd.Run(exportCmd, []string{}) + + // Verify both issues were exported + actualCount, err := countIssuesInJSONL(exportPath) + if err != nil { + t.Fatalf("Failed to count issues: %v", err) + } + if actualCount != 2 { + t.Errorf("Expected 2 issues in export with multiple ID filter, got %d", actualCount) + } + }) + + t.Run("export with parent filter", func(t *testing.T) { + exportPath := filepath.Join(tmpDir, "export_parent_filter.jsonl") + + // Create a parent issue (epic) + parentIssue := &types.Issue{ + Title: "Parent Epic", + Description: "Parent issue for testing", + Priority: 0, + IssueType: types.TypeEpic, + Status: types.StatusOpen, + } + if err := s.CreateIssue(ctx, parentIssue, "test-user"); err != nil { + t.Fatalf("Failed to create parent issue: %v", err) + } + + // Create child issues with parent-child dependency + childIssue1 := &types.Issue{ + Title: "Child Task 1", + Description: "First child of parent", + Priority: 1, + IssueType: types.TypeTask, + Status: types.StatusOpen, + } + if err := s.CreateIssue(ctx, childIssue1, "test-user"); err != nil { + t.Fatalf("Failed to create child issue 1: %v", err) + } + + childIssue2 := &types.Issue{ + Title: "Child Task 2", + Description: "Second child of parent", + Priority: 2, + IssueType: types.TypeTask, + Status: types.StatusOpen, + } + if err := s.CreateIssue(ctx, childIssue2, "test-user"); err != nil { + t.Fatalf("Failed to create child issue 2: %v", err) + } + + // Add parent-child dependencies + dep1 := &types.Dependency{ + IssueID: childIssue1.ID, + DependsOnID: parentIssue.ID, + Type: types.DepParentChild, + } + if err := s.AddDependency(ctx, dep1, "test-user"); err != nil { + t.Fatalf("Failed to add parent-child dependency 1: %v", err) + } + + dep2 := &types.Dependency{ + IssueID: childIssue2.ID, + DependsOnID: parentIssue.ID, + Type: types.DepParentChild, + } + if err := s.AddDependency(ctx, dep2, "test-user"); err != nil { + t.Fatalf("Failed to add parent-child dependency 2: %v", err) + } + + // Clear export hashes to force re-export + if err := s.ClearAllExportHashes(ctx); err != nil { + t.Fatalf("Failed to clear export hashes: %v", err) + } + + store = s + dbPath = testDB + rootCtx = ctx + defer func() { rootCtx = nil }() + + // Filter by parent ID + exportCmd.Flags().Set("output", exportPath) + exportCmd.Flags().Set("parent", parentIssue.ID) + defer exportCmd.Flags().Set("parent", "") // Reset flag after test + exportCmd.Run(exportCmd, []string{}) + + // Verify only children were exported (not the parent itself) + file, err := os.Open(exportPath) + if err != nil { + t.Fatalf("Failed to open export file: %v", err) + } + defer file.Close() + + scanner := bufio.NewScanner(file) + var exportedIDs []string + for scanner.Scan() { + var issue types.Issue + if err := json.Unmarshal(scanner.Bytes(), &issue); err != nil { + t.Fatalf("Failed to parse JSONL: %v", err) + } + exportedIDs = append(exportedIDs, issue.ID) + } + + // Should have exactly 2 children + if len(exportedIDs) != 2 { + t.Errorf("Expected 2 children in export with parent filter, got %d", len(exportedIDs)) + } + + // Verify the exported issues are the children, not the parent + for _, id := range exportedIDs { + if id == parentIssue.ID { + t.Error("Parent issue should not be included in parent filter results") + } + if id != childIssue1.ID && id != childIssue2.ID { + t.Errorf("Unexpected issue ID in export: %s", id) + } + } + }) + + t.Run("export with non-existent id filter", func(t *testing.T) { + exportPath := filepath.Join(tmpDir, "export_nonexistent_id.jsonl") + + // Clear export hashes to force re-export + if err := s.ClearAllExportHashes(ctx); err != nil { + t.Fatalf("Failed to clear export hashes: %v", err) + } + + store = s + dbPath = testDB + rootCtx = ctx + defer func() { rootCtx = nil }() + + // Filter by non-existent ID + exportCmd.Flags().Set("output", exportPath) + exportCmd.Flags().Set("id", "nonexistent-id-12345") + exportCmd.Flags().Set("force", "true") // Force to allow empty export + defer func() { + exportCmd.Flags().Set("id", "") + exportCmd.Flags().Set("force", "false") + }() + exportCmd.Run(exportCmd, []string{}) + + // Verify no issues were exported (file may not exist or be empty) + actualCount, err := countIssuesInJSONL(exportPath) + if err != nil && !os.IsNotExist(err) { + t.Fatalf("Failed to count issues: %v", err) + } + if actualCount != 0 { + t.Errorf("Expected 0 issues in export with non-existent ID filter, got %d", actualCount) + } + }) + + t.Run("filtered export skips staleness check", func(t *testing.T) { + exportPath := filepath.Join(tmpDir, "export_filtered_staleness.jsonl") + + // First, create a JSONL file with more issues than we'll filter for + // This would normally trigger the staleness check + file, err := os.Create(exportPath) + if err != nil { + t.Fatalf("Failed to create JSONL: %v", err) + } + encoder := json.NewEncoder(file) + // Write all issues including some that won't match our filter + for _, issue := range issues { + if err := encoder.Encode(issue); err != nil { + t.Fatalf("Failed to encode issue: %v", err) + } + } + // Add a fake issue that only exists in JSONL (would trigger staleness error) + fakeIssue := &types.Issue{ + ID: "fake-issue-999", + Title: "Fake Issue", + Description: "This issue only exists in JSONL", + Status: types.StatusOpen, + } + if err := encoder.Encode(fakeIssue); err != nil { + t.Fatalf("Failed to encode fake issue: %v", err) + } + file.Close() + + // Verify JSONL has 3 issues (2 real + 1 fake) + count, err := countIssuesInJSONL(exportPath) + if err != nil { + t.Fatalf("Failed to count issues: %v", err) + } + if count != 3 { + t.Fatalf("Expected 3 issues in JSONL, got %d", count) + } + + // Clear export hashes + if err := s.ClearAllExportHashes(ctx); err != nil { + t.Fatalf("Failed to clear export hashes: %v", err) + } + + store = s + dbPath = testDB + rootCtx = ctx + defer func() { rootCtx = nil }() + + // Export with --id filter for just one issue + // Without the fix, this would fail with "refusing to export stale database" + exportCmd.Flags().Set("output", exportPath) + exportCmd.Flags().Set("id", issues[0].ID) + defer exportCmd.Flags().Set("id", "") // Reset flag after test + exportCmd.Run(exportCmd, []string{}) + + // Verify export succeeded and only has 1 issue + actualCount, err := countIssuesInJSONL(exportPath) + if err != nil { + t.Fatalf("Failed to count issues after filtered export: %v", err) + } + if actualCount != 1 { + t.Errorf("Expected 1 issue in filtered export, got %d", actualCount) + } + }) + + t.Run("filtered export with zero results succeeds", func(t *testing.T) { + exportPath := filepath.Join(tmpDir, "export_filtered_empty.jsonl") + + // Create a JSONL with existing issues + file, err := os.Create(exportPath) + if err != nil { + t.Fatalf("Failed to create JSONL: %v", err) + } + encoder := json.NewEncoder(file) + for _, issue := range issues { + if err := encoder.Encode(issue); err != nil { + t.Fatalf("Failed to encode issue: %v", err) + } + } + file.Close() + + // Clear export hashes + if err := s.ClearAllExportHashes(ctx); err != nil { + t.Fatalf("Failed to clear export hashes: %v", err) + } + + store = s + dbPath = testDB + rootCtx = ctx + defer func() { rootCtx = nil }() + + // Export with --id filter for non-existent issue + // Without the fix, this would fail with "refusing to export empty database" + exportCmd.Flags().Set("output", exportPath) + exportCmd.Flags().Set("id", "nonexistent-id-xyz") + defer exportCmd.Flags().Set("id", "") // Reset flag after test + exportCmd.Run(exportCmd, []string{}) + + // Verify export succeeded (file should be empty or have 0 issues) + actualCount, err := countIssuesInJSONL(exportPath) + if err != nil && !os.IsNotExist(err) { + t.Fatalf("Failed to count issues after filtered export: %v", err) + } + if actualCount != 0 { + t.Errorf("Expected 0 issues in filtered export with non-existent ID, got %d", actualCount) + } + }) + t.Run("export cancellation", func(t *testing.T) { // Create a large number of issues to ensure export takes time ctx := context.Background()