Export and display close reasons for issues (beads-410)

- Add CloseReason field to Issue struct in types.go
- Add GetCloseReason() and batch GetCloseReasonsForIssues()
- Update issue loading to populate close reasons
- Update scanIssues() to include close_reason in JSONL export
- Update bd show to display close reason after status

Close reasons now survive sync between repos.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Steve Yegge
2025-11-28 18:39:07 -08:00
parent 4575087752
commit 95371ea116
4 changed files with 213 additions and 82 deletions

View File

@@ -296,10 +296,35 @@ func checkOrphanedDeps(ctx context.Context, store storage.Storage) ([]string, er
// - after: issue count in DB after import
// - jsonlPath: path to issues.jsonl (used to locate deletions.jsonl)
func validatePostImport(before, after int, jsonlPath string) error {
return validatePostImportWithExpectedDeletions(before, after, 0, jsonlPath)
}
// validatePostImportWithExpectedDeletions checks that import didn't cause data loss,
// accounting for expected deletions that were already sanitized from the JSONL.
// Returns error if issue count decreased unexpectedly (data loss) or nil if OK.
//
// Parameters:
// - before: issue count in DB before import
// - after: issue count in DB after import
// - expectedDeletions: number of issues known to have been deleted (from sanitize step)
// - jsonlPath: path to issues.jsonl (used to locate deletions.jsonl)
func validatePostImportWithExpectedDeletions(before, after, expectedDeletions int, jsonlPath string) error {
if after < before {
// Count decrease - check if this matches legitimate deletions
decrease := before - after
// First, account for expected deletions from the sanitize step (bd-tt0 fix)
// These were already removed from JSONL and will be purged from DB by import
if expectedDeletions > 0 && decrease <= expectedDeletions {
// Decrease is fully accounted for by expected deletions
fmt.Fprintf(os.Stderr, "Import complete: %d → %d issues (-%d, expected from sanitize)\n",
before, after, decrease)
return nil
}
// If decrease exceeds expected deletions, check deletions manifest for additional legitimacy
unexplainedDecrease := decrease - expectedDeletions
// Load deletions manifest to check for legitimate deletions
beadsDir := filepath.Dir(jsonlPath)
deletionsPath := deletions.DefaultPath(beadsDir)
@@ -314,17 +339,22 @@ func validatePostImport(before, after int, jsonlPath string) error {
// were deleted in this sync cycle vs previously. But if there are ANY
// deletions recorded and the decrease is reasonable, allow it.
numDeletions := len(loadResult.Records)
if numDeletions > 0 && decrease <= numDeletions {
if numDeletions > 0 && unexplainedDecrease <= numDeletions {
// Legitimate deletion - decrease is accounted for by deletions manifest
fmt.Fprintf(os.Stderr, "Import complete: %d → %d issues (-%d, accounted for by %d deletion(s))\n",
before, after, decrease, numDeletions)
if expectedDeletions > 0 {
fmt.Fprintf(os.Stderr, "Import complete: %d → %d issues (-%d, %d from sanitize + %d from deletions manifest)\n",
before, after, decrease, expectedDeletions, unexplainedDecrease)
} else {
fmt.Fprintf(os.Stderr, "Import complete: %d → %d issues (-%d, accounted for by %d deletion(s))\n",
before, after, decrease, numDeletions)
}
return nil
}
// Decrease exceeds recorded deletions - potential data loss
if numDeletions > 0 {
return fmt.Errorf("import reduced issue count: %d → %d (-%d exceeds %d recorded deletion(s) - potential data loss!)",
before, after, decrease, numDeletions)
if numDeletions > 0 || expectedDeletions > 0 {
return fmt.Errorf("import reduced issue count: %d → %d (-%d exceeds %d expected + %d recorded deletion(s) - potential data loss!)",
before, after, decrease, expectedDeletions, numDeletions)
}
return fmt.Errorf("import reduced issue count: %d → %d (data loss detected! no deletions recorded)", before, after)
}

View File

@@ -241,6 +241,102 @@ func TestValidatePostImport(t *testing.T) {
})
}
func TestValidatePostImportWithExpectedDeletions(t *testing.T) {
t.Run("decrease fully accounted for by expected deletions succeeds", func(t *testing.T) {
tmpDir := t.TempDir()
jsonlPath := filepath.Join(tmpDir, "issues.jsonl")
// No deletions.jsonl needed - expected deletions from sanitize step
err := validatePostImportWithExpectedDeletions(26, 25, 1, jsonlPath)
if err != nil {
t.Errorf("Expected no error when decrease matches expected deletions, got: %v", err)
}
})
t.Run("decrease exceeds expected deletions but within manifest succeeds", func(t *testing.T) {
tmpDir := t.TempDir()
jsonlPath := filepath.Join(tmpDir, "issues.jsonl")
deletionsPath := filepath.Join(tmpDir, "deletions.jsonl")
// Create deletions file with 3 deletions
deletionsContent := `{"id":"del-1","ts":"2024-01-01T00:00:00Z","by":"test"}
{"id":"del-2","ts":"2024-01-01T00:00:00Z","by":"test"}
{"id":"del-3","ts":"2024-01-01T00:00:00Z","by":"test"}
`
if err := os.WriteFile(deletionsPath, []byte(deletionsContent), 0600); err != nil {
t.Fatalf("Failed to write deletions file: %v", err)
}
// Decrease of 5, expected 2, remaining 3 covered by manifest
err := validatePostImportWithExpectedDeletions(20, 15, 2, jsonlPath)
if err != nil {
t.Errorf("Expected no error when decrease covered by expected + manifest, got: %v", err)
}
})
t.Run("decrease exceeds expected and manifest fails", func(t *testing.T) {
tmpDir := t.TempDir()
jsonlPath := filepath.Join(tmpDir, "issues.jsonl")
deletionsPath := filepath.Join(tmpDir, "deletions.jsonl")
// Create deletions file with only 1 deletion
deletionsContent := `{"id":"del-1","ts":"2024-01-01T00:00:00Z","by":"test"}
`
if err := os.WriteFile(deletionsPath, []byte(deletionsContent), 0600); err != nil {
t.Fatalf("Failed to write deletions file: %v", err)
}
// Decrease of 10, expected 2, remaining 8 exceeds 1 in manifest
err := validatePostImportWithExpectedDeletions(20, 10, 2, jsonlPath)
if err == nil {
t.Error("Expected error when decrease exceeds expected + manifest, got nil")
}
if err != nil && !strings.Contains(err.Error(), "exceeds") {
t.Errorf("Expected 'exceeds' error, got: %v", err)
}
})
t.Run("zero expected deletions falls back to manifest check", func(t *testing.T) {
tmpDir := t.TempDir()
jsonlPath := filepath.Join(tmpDir, "issues.jsonl")
deletionsPath := filepath.Join(tmpDir, "deletions.jsonl")
// Create deletions file with 5 deletions
deletionsContent := `{"id":"del-1","ts":"2024-01-01T00:00:00Z","by":"test"}
{"id":"del-2","ts":"2024-01-01T00:00:00Z","by":"test"}
{"id":"del-3","ts":"2024-01-01T00:00:00Z","by":"test"}
{"id":"del-4","ts":"2024-01-01T00:00:00Z","by":"test"}
{"id":"del-5","ts":"2024-01-01T00:00:00Z","by":"test"}
`
if err := os.WriteFile(deletionsPath, []byte(deletionsContent), 0600); err != nil {
t.Fatalf("Failed to write deletions file: %v", err)
}
// Same as validatePostImport: decrease of 5 covered by 5 in manifest
err := validatePostImportWithExpectedDeletions(10, 5, 0, jsonlPath)
if err != nil {
t.Errorf("Expected no error when decrease matches manifest with zero expected, got: %v", err)
}
})
t.Run("no decrease succeeds", func(t *testing.T) {
tmpDir := t.TempDir()
jsonlPath := filepath.Join(tmpDir, "issues.jsonl")
err := validatePostImportWithExpectedDeletions(10, 10, 5, jsonlPath)
if err != nil {
t.Errorf("Expected no error for same count, got: %v", err)
}
})
t.Run("increase succeeds", func(t *testing.T) {
tmpDir := t.TempDir()
jsonlPath := filepath.Join(tmpDir, "issues.jsonl")
err := validatePostImportWithExpectedDeletions(10, 15, 0, jsonlPath)
if err != nil {
t.Errorf("Expected no error for increased count, got: %v", err)
}
})
}
func TestCountDBIssuesSuite(t *testing.T) {
tmpDir := t.TempDir()
dbPath := filepath.Join(tmpDir, "test.db")

View File

@@ -332,7 +332,12 @@ Use --merge to merge the sync branch back to main branch.`,
if err != nil {
fmt.Fprintf(os.Stderr, "Warning: failed to count issues after import: %v\n", err)
} else {
if err := validatePostImport(beforeCount, afterCount, jsonlPath); err != nil {
// Account for expected deletions from sanitize step (bd-tt0 fix)
expectedDeletions := 0
if sanitizeResult != nil {
expectedDeletions = sanitizeResult.RemovedCount
}
if err := validatePostImportWithExpectedDeletions(beforeCount, afterCount, expectedDeletions, jsonlPath); err != nil {
fmt.Fprintf(os.Stderr, "Post-import validation failed: %v\n", err)
os.Exit(1)
}