* feat: enhance bd doctor sync detection with count and prefix mismatch checks Improves bd doctor to detect actual database-JSONL sync issues instead of relying only on file modification times: Key improvements: 1. Count detection: Reports when database issue count differs from JSONL (e.g., "Count mismatch: database has 0 issues, JSONL has 61") 2. Prefix detection: Identifies prefix mismatches when majority of JSONL issues use different prefix than database config 3. Error handling: Returns errors from helper functions instead of silent failures, distinguishing "can't open DB" from "counts differ" 4. Query optimization: Single database connection for all checks (reduced from 3 opens to 1) 5. Better error reporting: Shows actual error details when database or JSONL can't be read This addresses the core issue where bd doctor would incorrectly report "Database and JSONL are in sync" when the database was empty but JSONL contained issues (as happened in privacy2 project). Tests: - Added TestCountJSONLIssuesWithMalformedLines to verify malformed JSON handling - Existing doctor tests still pass - countJSONLIssues now returns error to indicate parsing issues 🤖 Generated with Claude Code Co-Authored-By: Claude <noreply@anthropic.com> * fix: correct git hooks installation instructions in bd doctor The original message referenced './examples/git-hooks/install.sh' which doesn't exist in user projects. This fix changes the message to point to the actual location in the beads GitHub repository: Before: "Run './examples/git-hooks/install.sh' to install recommended git hooks" After: "See https://github.com/steveyegge/beads/tree/main/examples/git-hooks for installation instructions" This works for any project using bd, not just the beads repository itself. 🤖 Generated with Claude Code Co-Authored-By: Claude <noreply@anthropic.com> * feat: add recovery suggestions when database fails but JSONL has issues When bd doctor detects that the database cannot be opened/queried but the JSONL file contains issues, it now suggests the recovery command: Fix: Run 'bd import -i issues.jsonl --rename-on-import' to recover issues from JSONL This addresses the case where: - Database is corrupted or inaccessible - JSONL has all the issues backed up - User needs a clear path to recover The check now: 1. Reads JSONL first (doesn't depend on database) 2. If database fails but JSONL has issues, suggests recovery command 3. If database can be queried, continues with sync checks as before Tested on privacy2 project which has 61 issues in JSONL but inaccessible database. 🤖 Generated with Claude Code Co-Authored-By: Claude <noreply@anthropic.com> * fix: support hash-based issue IDs in import rename The import --rename-on-import flag was rejecting valid issue IDs with hash-based suffixes (e.g., privacy-09ea) because the validation only accepted numeric suffixes. Beads now generates and accepts base36-encoded hash IDs, so update the validation to match. Changes: - Update isNumeric() to accept base36 characters (0-9, a-z) - Update tests to reflect hash-based ID support - Add gosec nolint comment for safe file path construction Fixes the error: "cannot rename issue privacy-09ea: non-numeric suffix '09ea'" --------- Co-authored-by: Claude <noreply@anthropic.com>
285 lines
7.1 KiB
Go
285 lines
7.1 KiB
Go
package importer
|
|
|
|
import (
|
|
"fmt"
|
|
"sort"
|
|
"strings"
|
|
|
|
"github.com/steveyegge/beads/internal/types"
|
|
"github.com/steveyegge/beads/internal/utils"
|
|
)
|
|
|
|
// IssueDataChanged checks if an issue's data has changed from the database version
|
|
func IssueDataChanged(existing *types.Issue, updates map[string]interface{}) bool {
|
|
fc := newFieldComparator()
|
|
for key, newVal := range updates {
|
|
if fc.checkFieldChanged(key, existing, newVal) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// fieldComparator handles comparison logic for different field types
|
|
type fieldComparator struct {
|
|
strFrom func(v interface{}) (string, bool)
|
|
intFrom func(v interface{}) (int64, bool)
|
|
}
|
|
|
|
func newFieldComparator() *fieldComparator {
|
|
fc := &fieldComparator{}
|
|
|
|
fc.strFrom = func(v interface{}) (string, bool) {
|
|
switch t := v.(type) {
|
|
case string:
|
|
return t, true
|
|
case *string:
|
|
if t == nil {
|
|
return "", true
|
|
}
|
|
return *t, true
|
|
case nil:
|
|
return "", true
|
|
default:
|
|
return "", false
|
|
}
|
|
}
|
|
|
|
fc.intFrom = func(v interface{}) (int64, bool) {
|
|
switch t := v.(type) {
|
|
case int:
|
|
return int64(t), true
|
|
case int32:
|
|
return int64(t), true
|
|
case int64:
|
|
return t, true
|
|
case float64:
|
|
if t == float64(int64(t)) {
|
|
return int64(t), true
|
|
}
|
|
return 0, false
|
|
default:
|
|
return 0, false
|
|
}
|
|
}
|
|
|
|
return fc
|
|
}
|
|
|
|
func (fc *fieldComparator) equalStr(existingVal string, newVal interface{}) bool {
|
|
s, ok := fc.strFrom(newVal)
|
|
if !ok {
|
|
return false
|
|
}
|
|
return existingVal == s
|
|
}
|
|
|
|
func (fc *fieldComparator) equalPtrStr(existing *string, newVal interface{}) bool {
|
|
s, ok := fc.strFrom(newVal)
|
|
if !ok {
|
|
return false
|
|
}
|
|
if existing == nil {
|
|
return s == ""
|
|
}
|
|
return *existing == s
|
|
}
|
|
|
|
func (fc *fieldComparator) equalStatus(existing types.Status, newVal interface{}) bool {
|
|
switch t := newVal.(type) {
|
|
case types.Status:
|
|
return existing == t
|
|
case string:
|
|
return string(existing) == t
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func (fc *fieldComparator) equalIssueType(existing types.IssueType, newVal interface{}) bool {
|
|
switch t := newVal.(type) {
|
|
case types.IssueType:
|
|
return existing == t
|
|
case string:
|
|
return string(existing) == t
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func (fc *fieldComparator) equalPriority(existing int, newVal interface{}) bool {
|
|
newPriority, ok := fc.intFrom(newVal)
|
|
return ok && int64(existing) == newPriority
|
|
}
|
|
|
|
func (fc *fieldComparator) checkFieldChanged(key string, existing *types.Issue, newVal interface{}) bool {
|
|
switch key {
|
|
case "title":
|
|
return !fc.equalStr(existing.Title, newVal)
|
|
case "description":
|
|
return !fc.equalStr(existing.Description, newVal)
|
|
case "status":
|
|
return !fc.equalStatus(existing.Status, newVal)
|
|
case "priority":
|
|
return !fc.equalPriority(existing.Priority, newVal)
|
|
case "issue_type":
|
|
return !fc.equalIssueType(existing.IssueType, newVal)
|
|
case "design":
|
|
return !fc.equalStr(existing.Design, newVal)
|
|
case "acceptance_criteria":
|
|
return !fc.equalStr(existing.AcceptanceCriteria, newVal)
|
|
case "notes":
|
|
return !fc.equalStr(existing.Notes, newVal)
|
|
case "assignee":
|
|
return !fc.equalStr(existing.Assignee, newVal)
|
|
case "external_ref":
|
|
return !fc.equalPtrStr(existing.ExternalRef, newVal)
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
// RenameImportedIssuePrefixes renames all issues and their references to match the target prefix
|
|
func RenameImportedIssuePrefixes(issues []*types.Issue, targetPrefix string) error {
|
|
// Build a mapping of old IDs to new IDs
|
|
idMapping := make(map[string]string)
|
|
|
|
for _, issue := range issues {
|
|
oldPrefix := utils.ExtractIssuePrefix(issue.ID)
|
|
if oldPrefix == "" {
|
|
return fmt.Errorf("cannot rename issue %s: malformed ID (no hyphen found)", issue.ID)
|
|
}
|
|
|
|
if oldPrefix != targetPrefix {
|
|
// Extract the numeric part
|
|
numPart := strings.TrimPrefix(issue.ID, oldPrefix+"-")
|
|
|
|
// Validate that the numeric part is actually numeric
|
|
if numPart == "" || !isNumeric(numPart) {
|
|
return fmt.Errorf("cannot rename issue %s: non-numeric suffix '%s'", issue.ID, numPart)
|
|
}
|
|
|
|
newID := fmt.Sprintf("%s-%s", targetPrefix, numPart)
|
|
idMapping[issue.ID] = newID
|
|
}
|
|
}
|
|
|
|
// Now update all issues and their references
|
|
for _, issue := range issues {
|
|
// Update the issue ID itself if it needs renaming
|
|
if newID, ok := idMapping[issue.ID]; ok {
|
|
issue.ID = newID
|
|
}
|
|
|
|
// Update all text references in issue fields
|
|
issue.Title = replaceIDReferences(issue.Title, idMapping)
|
|
issue.Description = replaceIDReferences(issue.Description, idMapping)
|
|
if issue.Design != "" {
|
|
issue.Design = replaceIDReferences(issue.Design, idMapping)
|
|
}
|
|
if issue.AcceptanceCriteria != "" {
|
|
issue.AcceptanceCriteria = replaceIDReferences(issue.AcceptanceCriteria, idMapping)
|
|
}
|
|
if issue.Notes != "" {
|
|
issue.Notes = replaceIDReferences(issue.Notes, idMapping)
|
|
}
|
|
|
|
// Update dependency references
|
|
for i := range issue.Dependencies {
|
|
if newID, ok := idMapping[issue.Dependencies[i].IssueID]; ok {
|
|
issue.Dependencies[i].IssueID = newID
|
|
}
|
|
if newID, ok := idMapping[issue.Dependencies[i].DependsOnID]; ok {
|
|
issue.Dependencies[i].DependsOnID = newID
|
|
}
|
|
}
|
|
|
|
// Update comment references
|
|
for i := range issue.Comments {
|
|
issue.Comments[i].Text = replaceIDReferences(issue.Comments[i].Text, idMapping)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// replaceIDReferences replaces all old issue ID references with new ones in text
|
|
func replaceIDReferences(text string, idMapping map[string]string) string {
|
|
if len(idMapping) == 0 {
|
|
return text
|
|
}
|
|
|
|
// Sort old IDs by length descending to handle longer IDs first
|
|
oldIDs := make([]string, 0, len(idMapping))
|
|
for oldID := range idMapping {
|
|
oldIDs = append(oldIDs, oldID)
|
|
}
|
|
sort.Slice(oldIDs, func(i, j int) bool {
|
|
return len(oldIDs[i]) > len(oldIDs[j])
|
|
})
|
|
|
|
result := text
|
|
for _, oldID := range oldIDs {
|
|
newID := idMapping[oldID]
|
|
result = replaceBoundaryAware(result, oldID, newID)
|
|
}
|
|
return result
|
|
}
|
|
|
|
// replaceBoundaryAware replaces oldID with newID only when surrounded by boundaries
|
|
func replaceBoundaryAware(text, oldID, newID string) string {
|
|
if !strings.Contains(text, oldID) {
|
|
return text
|
|
}
|
|
|
|
var result strings.Builder
|
|
i := 0
|
|
for i < len(text) {
|
|
// Find next occurrence
|
|
idx := strings.Index(text[i:], oldID)
|
|
if idx == -1 {
|
|
result.WriteString(text[i:])
|
|
break
|
|
}
|
|
|
|
actualIdx := i + idx
|
|
// Check boundary before
|
|
beforeOK := actualIdx == 0 || isBoundary(text[actualIdx-1])
|
|
// Check boundary after
|
|
afterIdx := actualIdx + len(oldID)
|
|
afterOK := afterIdx >= len(text) || isBoundary(text[afterIdx])
|
|
|
|
// Write up to this match
|
|
result.WriteString(text[i:actualIdx])
|
|
|
|
if beforeOK && afterOK {
|
|
// Valid match - replace
|
|
result.WriteString(newID)
|
|
} else {
|
|
// Invalid match - keep original
|
|
result.WriteString(oldID)
|
|
}
|
|
|
|
i = afterIdx
|
|
}
|
|
|
|
return result.String()
|
|
}
|
|
|
|
func isBoundary(c byte) bool {
|
|
return c == ' ' || c == '\t' || c == '\n' || c == '\r' || c == ',' || c == '.' || c == '!' || c == '?' || c == ':' || c == ';' || c == '(' || c == ')' || c == '[' || c == ']' || c == '{' || c == '}'
|
|
}
|
|
|
|
func isNumeric(s string) bool {
|
|
if len(s) == 0 {
|
|
return false
|
|
}
|
|
// Accept base36 characters (0-9, a-z) for hash-based suffixes
|
|
for _, c := range s {
|
|
if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'z')) {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|