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 <noreply@anthropic.com>
This commit is contained in:
committed by
GitHub
parent
e45a7489b2
commit
4669de7625
@@ -150,6 +150,8 @@ Examples:
|
|||||||
createdBefore, _ := cmd.Flags().GetString("created-before")
|
createdBefore, _ := cmd.Flags().GetString("created-before")
|
||||||
updatedAfter, _ := cmd.Flags().GetString("updated-after")
|
updatedAfter, _ := cmd.Flags().GetString("updated-after")
|
||||||
updatedBefore, _ := cmd.Flags().GetString("updated-before")
|
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)
|
debug.Logf("Debug: export flags - output=%q, force=%v\n", output, force)
|
||||||
|
|
||||||
@@ -225,6 +227,15 @@ Examples:
|
|||||||
if len(labelsAny) > 0 {
|
if len(labelsAny) > 0 {
|
||||||
filter.LabelsAny = labelsAny
|
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)
|
// Priority exact match (use Changed() to properly handle P0)
|
||||||
if cmd.Flags().Changed("priority") {
|
if cmd.Flags().Changed("priority") {
|
||||||
@@ -297,8 +308,27 @@ Examples:
|
|||||||
os.Exit(1)
|
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
|
// 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)
|
existingCount, err := countIssuesInJSONL(output)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// If we can't read the file, it might not exist yet, which is fine
|
// 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
|
// 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)
|
debug.Logf("Debug: checking staleness - output=%s, force=%v\n", output, force)
|
||||||
|
|
||||||
// Read existing JSONL to get issue IDs
|
// 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-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)")
|
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)
|
rootCmd.AddCommand(exportCmd)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
t.Run("export cancellation", func(t *testing.T) {
|
||||||
// Create a large number of issues to ensure export takes time
|
// Create a large number of issues to ensure export takes time
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|||||||
Reference in New Issue
Block a user