feat: Add external_ref field for linking to external issue trackers

Add nullable external_ref TEXT field to link bd issues with external
systems like GitHub Issues, Jira, etc. Includes automatic schema
migration for backward compatibility.

Changes:
- Added external_ref column to issues table with feature-based migration
- Updated Issue struct with ExternalRef *string field
- Added --external-ref flag to bd create and bd update commands
- Updated all SQL queries across the codebase to include external_ref:
  - GetIssue, CreateIssue, UpdateIssue, SearchIssues
  - GetDependencies, GetDependents, GetDependencyTree
  - GetReadyWork, GetBlockedIssues, GetIssuesByLabel
- Added external_ref handling in import/export logic
- Follows existing patterns for nullable fields (sql.NullString)

This enables tracking relationships between bd issues and external
systems without requiring changes to existing databases or JSONL files.

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Steve Yegge
2025-10-14 02:43:10 -07:00
parent 287c3144c4
commit e6be7dd3e8
8 changed files with 105 additions and 14 deletions

View File

@@ -222,6 +222,13 @@ Behavior:
updates["estimated_minutes"] = nil updates["estimated_minutes"] = nil
} }
} }
if _, ok := rawData["external_ref"]; ok {
if issue.ExternalRef != nil {
updates["external_ref"] = *issue.ExternalRef
} else {
updates["external_ref"] = nil
}
}
if err := store.UpdateIssue(ctx, issue.ID, updates, "import"); err != nil { if err := store.UpdateIssue(ctx, issue.ID, updates, "import"); err != nil {
fmt.Fprintf(os.Stderr, "Error updating issue %s: %v\n", issue.ID, err) fmt.Fprintf(os.Stderr, "Error updating issue %s: %v\n", issue.ID, err)

View File

@@ -238,6 +238,9 @@ func autoImportIfNewer() {
if issue.EstimatedMinutes != nil { if issue.EstimatedMinutes != nil {
updates["estimated_minutes"] = *issue.EstimatedMinutes updates["estimated_minutes"] = *issue.EstimatedMinutes
} }
if issue.ExternalRef != nil {
updates["external_ref"] = *issue.ExternalRef
}
_ = store.UpdateIssue(ctx, issue.ID, updates, "auto-import") _ = store.UpdateIssue(ctx, issue.ID, updates, "auto-import")
} else { } else {
@@ -512,6 +515,7 @@ var createCmd = &cobra.Command{
assignee, _ := cmd.Flags().GetString("assignee") assignee, _ := cmd.Flags().GetString("assignee")
labels, _ := cmd.Flags().GetStringSlice("labels") labels, _ := cmd.Flags().GetStringSlice("labels")
explicitID, _ := cmd.Flags().GetString("id") explicitID, _ := cmd.Flags().GetString("id")
externalRef, _ := cmd.Flags().GetString("external-ref")
// Validate explicit ID format if provided (prefix-number) // Validate explicit ID format if provided (prefix-number)
if explicitID != "" { if explicitID != "" {
@@ -528,6 +532,11 @@ var createCmd = &cobra.Command{
} }
} }
var externalRefPtr *string
if externalRef != "" {
externalRefPtr = &externalRef
}
issue := &types.Issue{ issue := &types.Issue{
ID: explicitID, // Set explicit ID if provided (empty string if not) ID: explicitID, // Set explicit ID if provided (empty string if not)
Title: title, Title: title,
@@ -538,6 +547,7 @@ var createCmd = &cobra.Command{
Priority: priority, Priority: priority,
IssueType: types.IssueType(issueType), IssueType: types.IssueType(issueType),
Assignee: assignee, Assignee: assignee,
ExternalRef: externalRefPtr,
} }
ctx := context.Background() ctx := context.Background()
@@ -577,6 +587,7 @@ func init() {
createCmd.Flags().StringP("assignee", "a", "", "Assignee") createCmd.Flags().StringP("assignee", "a", "", "Assignee")
createCmd.Flags().StringSliceP("labels", "l", []string{}, "Labels (comma-separated)") createCmd.Flags().StringSliceP("labels", "l", []string{}, "Labels (comma-separated)")
createCmd.Flags().String("id", "", "Explicit issue ID (e.g., 'bd-42' for partitioning)") createCmd.Flags().String("id", "", "Explicit issue ID (e.g., 'bd-42' for partitioning)")
createCmd.Flags().String("external-ref", "", "External reference (e.g., 'gh-9', 'jira-ABC')")
rootCmd.AddCommand(createCmd) rootCmd.AddCommand(createCmd)
} }
@@ -768,6 +779,10 @@ var updateCmd = &cobra.Command{
acceptanceCriteria, _ := cmd.Flags().GetString("acceptance-criteria") acceptanceCriteria, _ := cmd.Flags().GetString("acceptance-criteria")
updates["acceptance_criteria"] = acceptanceCriteria updates["acceptance_criteria"] = acceptanceCriteria
} }
if cmd.Flags().Changed("external-ref") {
externalRef, _ := cmd.Flags().GetString("external-ref")
updates["external_ref"] = externalRef
}
if len(updates) == 0 { if len(updates) == 0 {
fmt.Println("No updates specified") fmt.Println("No updates specified")
@@ -802,6 +817,7 @@ func init() {
updateCmd.Flags().String("design", "", "Design notes") updateCmd.Flags().String("design", "", "Design notes")
updateCmd.Flags().String("notes", "", "Additional notes") updateCmd.Flags().String("notes", "", "Additional notes")
updateCmd.Flags().String("acceptance-criteria", "", "Acceptance criteria") updateCmd.Flags().String("acceptance-criteria", "", "Acceptance criteria")
updateCmd.Flags().String("external-ref", "", "External reference (e.g., 'gh-9', 'jira-ABC')")
rootCmd.AddCommand(updateCmd) rootCmd.AddCommand(updateCmd)
} }

View File

@@ -156,7 +156,7 @@ func (s *SQLiteStorage) GetDependencies(ctx context.Context, issueID string) ([]
rows, err := s.db.QueryContext(ctx, ` rows, err := s.db.QueryContext(ctx, `
SELECT i.id, i.title, i.description, i.design, i.acceptance_criteria, i.notes, SELECT i.id, i.title, i.description, i.design, i.acceptance_criteria, i.notes,
i.status, i.priority, i.issue_type, i.assignee, i.estimated_minutes, i.status, i.priority, i.issue_type, i.assignee, i.estimated_minutes,
i.created_at, i.updated_at, i.closed_at i.created_at, i.updated_at, i.closed_at, i.external_ref
FROM issues i FROM issues i
JOIN dependencies d ON i.id = d.depends_on_id JOIN dependencies d ON i.id = d.depends_on_id
WHERE d.issue_id = ? WHERE d.issue_id = ?
@@ -175,7 +175,7 @@ func (s *SQLiteStorage) GetDependents(ctx context.Context, issueID string) ([]*t
rows, err := s.db.QueryContext(ctx, ` rows, err := s.db.QueryContext(ctx, `
SELECT i.id, i.title, i.description, i.design, i.acceptance_criteria, i.notes, SELECT i.id, i.title, i.description, i.design, i.acceptance_criteria, i.notes,
i.status, i.priority, i.issue_type, i.assignee, i.estimated_minutes, i.status, i.priority, i.issue_type, i.assignee, i.estimated_minutes,
i.created_at, i.updated_at, i.closed_at i.created_at, i.updated_at, i.closed_at, i.external_ref
FROM issues i FROM issues i
JOIN dependencies d ON i.id = d.issue_id JOIN dependencies d ON i.id = d.issue_id
WHERE d.depends_on_id = ? WHERE d.depends_on_id = ?
@@ -267,6 +267,7 @@ func (s *SQLiteStorage) GetDependencyTree(ctx context.Context, issueID string, m
i.id, i.title, i.status, i.priority, i.description, i.design, i.id, i.title, i.status, i.priority, i.description, i.design,
i.acceptance_criteria, i.notes, i.issue_type, i.assignee, i.acceptance_criteria, i.notes, i.issue_type, i.assignee,
i.estimated_minutes, i.created_at, i.updated_at, i.closed_at, i.estimated_minutes, i.created_at, i.updated_at, i.closed_at,
i.external_ref,
0 as depth 0 as depth
FROM issues i FROM issues i
WHERE i.id = ? WHERE i.id = ?
@@ -277,6 +278,7 @@ func (s *SQLiteStorage) GetDependencyTree(ctx context.Context, issueID string, m
i.id, i.title, i.status, i.priority, i.description, i.design, i.id, i.title, i.status, i.priority, i.description, i.design,
i.acceptance_criteria, i.notes, i.issue_type, i.assignee, i.acceptance_criteria, i.notes, i.issue_type, i.assignee,
i.estimated_minutes, i.created_at, i.updated_at, i.closed_at, i.estimated_minutes, i.created_at, i.updated_at, i.closed_at,
i.external_ref,
t.depth + 1 t.depth + 1
FROM issues i FROM issues i
JOIN dependencies d ON i.id = d.depends_on_id JOIN dependencies d ON i.id = d.depends_on_id
@@ -297,12 +299,13 @@ func (s *SQLiteStorage) GetDependencyTree(ctx context.Context, issueID string, m
var closedAt sql.NullTime var closedAt sql.NullTime
var estimatedMinutes sql.NullInt64 var estimatedMinutes sql.NullInt64
var assignee sql.NullString var assignee sql.NullString
var externalRef sql.NullString
err := rows.Scan( err := rows.Scan(
&node.ID, &node.Title, &node.Status, &node.Priority, &node.ID, &node.Title, &node.Status, &node.Priority,
&node.Description, &node.Design, &node.AcceptanceCriteria, &node.Description, &node.Design, &node.AcceptanceCriteria,
&node.Notes, &node.IssueType, &assignee, &estimatedMinutes, &node.Notes, &node.IssueType, &assignee, &estimatedMinutes,
&node.CreatedAt, &node.UpdatedAt, &closedAt, &node.Depth, &node.CreatedAt, &node.UpdatedAt, &closedAt, &externalRef, &node.Depth,
) )
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to scan tree node: %w", err) return nil, fmt.Errorf("failed to scan tree node: %w", err)
@@ -318,6 +321,9 @@ func (s *SQLiteStorage) GetDependencyTree(ctx context.Context, issueID string, m
if assignee.Valid { if assignee.Valid {
node.Assignee = assignee.String node.Assignee = assignee.String
} }
if externalRef.Valid {
node.ExternalRef = &externalRef.String
}
node.Truncated = node.Depth == maxDepth node.Truncated = node.Depth == maxDepth
@@ -415,12 +421,13 @@ func scanIssues(rows *sql.Rows) ([]*types.Issue, error) {
var closedAt sql.NullTime var closedAt sql.NullTime
var estimatedMinutes sql.NullInt64 var estimatedMinutes sql.NullInt64
var assignee sql.NullString var assignee sql.NullString
var externalRef sql.NullString
err := rows.Scan( err := rows.Scan(
&issue.ID, &issue.Title, &issue.Description, &issue.Design, &issue.ID, &issue.Title, &issue.Description, &issue.Design,
&issue.AcceptanceCriteria, &issue.Notes, &issue.Status, &issue.AcceptanceCriteria, &issue.Notes, &issue.Status,
&issue.Priority, &issue.IssueType, &assignee, &estimatedMinutes, &issue.Priority, &issue.IssueType, &assignee, &estimatedMinutes,
&issue.CreatedAt, &issue.UpdatedAt, &closedAt, &issue.CreatedAt, &issue.UpdatedAt, &closedAt, &externalRef,
) )
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to scan issue: %w", err) return nil, fmt.Errorf("failed to scan issue: %w", err)
@@ -436,6 +443,9 @@ func scanIssues(rows *sql.Rows) ([]*types.Issue, error) {
if assignee.Valid { if assignee.Valid {
issue.Assignee = assignee.String issue.Assignee = assignee.String
} }
if externalRef.Valid {
issue.ExternalRef = &externalRef.String
}
issues = append(issues, &issue) issues = append(issues, &issue)
} }

View File

@@ -108,7 +108,7 @@ func (s *SQLiteStorage) GetIssuesByLabel(ctx context.Context, label string) ([]*
rows, err := s.db.QueryContext(ctx, ` rows, err := s.db.QueryContext(ctx, `
SELECT i.id, i.title, i.description, i.design, i.acceptance_criteria, i.notes, SELECT i.id, i.title, i.description, i.design, i.acceptance_criteria, i.notes,
i.status, i.priority, i.issue_type, i.assignee, i.estimated_minutes, i.status, i.priority, i.issue_type, i.assignee, i.estimated_minutes,
i.created_at, i.updated_at, i.closed_at i.created_at, i.updated_at, i.closed_at, i.external_ref
FROM issues i FROM issues i
JOIN labels l ON i.id = l.issue_id JOIN labels l ON i.id = l.issue_id
WHERE l.label = ? WHERE l.label = ?

View File

@@ -46,7 +46,7 @@ func (s *SQLiteStorage) GetReadyWork(ctx context.Context, filter types.WorkFilte
query := fmt.Sprintf(` query := fmt.Sprintf(`
SELECT i.id, i.title, i.description, i.design, i.acceptance_criteria, i.notes, SELECT i.id, i.title, i.description, i.design, i.acceptance_criteria, i.notes,
i.status, i.priority, i.issue_type, i.assignee, i.estimated_minutes, i.status, i.priority, i.issue_type, i.assignee, i.estimated_minutes,
i.created_at, i.updated_at, i.closed_at i.created_at, i.updated_at, i.closed_at, i.external_ref
FROM issues i FROM issues i
WHERE %s WHERE %s
AND NOT EXISTS ( AND NOT EXISTS (
@@ -76,7 +76,7 @@ func (s *SQLiteStorage) GetBlockedIssues(ctx context.Context) ([]*types.BlockedI
SELECT SELECT
i.id, i.title, i.description, i.design, i.acceptance_criteria, i.notes, i.id, i.title, i.description, i.design, i.acceptance_criteria, i.notes,
i.status, i.priority, i.issue_type, i.assignee, i.estimated_minutes, i.status, i.priority, i.issue_type, i.assignee, i.estimated_minutes,
i.created_at, i.updated_at, i.closed_at, i.created_at, i.updated_at, i.closed_at, i.external_ref,
COUNT(d.depends_on_id) as blocked_by_count, COUNT(d.depends_on_id) as blocked_by_count,
GROUP_CONCAT(d.depends_on_id, ',') as blocker_ids GROUP_CONCAT(d.depends_on_id, ',') as blocker_ids
FROM issues i FROM issues i
@@ -99,13 +99,14 @@ func (s *SQLiteStorage) GetBlockedIssues(ctx context.Context) ([]*types.BlockedI
var closedAt sql.NullTime var closedAt sql.NullTime
var estimatedMinutes sql.NullInt64 var estimatedMinutes sql.NullInt64
var assignee sql.NullString var assignee sql.NullString
var externalRef sql.NullString
var blockerIDsStr string var blockerIDsStr string
err := rows.Scan( err := rows.Scan(
&issue.ID, &issue.Title, &issue.Description, &issue.Design, &issue.ID, &issue.Title, &issue.Description, &issue.Design,
&issue.AcceptanceCriteria, &issue.Notes, &issue.Status, &issue.AcceptanceCriteria, &issue.Notes, &issue.Status,
&issue.Priority, &issue.IssueType, &assignee, &estimatedMinutes, &issue.Priority, &issue.IssueType, &assignee, &estimatedMinutes,
&issue.CreatedAt, &issue.UpdatedAt, &closedAt, &issue.BlockedByCount, &issue.CreatedAt, &issue.UpdatedAt, &closedAt, &externalRef, &issue.BlockedByCount,
&blockerIDsStr, &blockerIDsStr,
) )
if err != nil { if err != nil {
@@ -122,6 +123,9 @@ func (s *SQLiteStorage) GetBlockedIssues(ctx context.Context) ([]*types.BlockedI
if assignee.Valid { if assignee.Valid {
issue.Assignee = assignee.String issue.Assignee = assignee.String
} }
if externalRef.Valid {
issue.ExternalRef = &externalRef.String
}
// Parse comma-separated blocker IDs // Parse comma-separated blocker IDs
if blockerIDsStr != "" { if blockerIDsStr != "" {

View File

@@ -16,7 +16,8 @@ CREATE TABLE IF NOT EXISTS issues (
estimated_minutes INTEGER, estimated_minutes INTEGER,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
closed_at DATETIME closed_at DATETIME,
external_ref TEXT
); );
CREATE INDEX IF NOT EXISTS idx_issues_status ON issues(status); CREATE INDEX IF NOT EXISTS idx_issues_status ON issues(status);

View File

@@ -55,6 +55,11 @@ func New(path string) (*SQLiteStorage, error) {
return nil, fmt.Errorf("failed to migrate issue_counters table: %w", err) return nil, fmt.Errorf("failed to migrate issue_counters table: %w", err)
} }
// Migrate existing databases to add external_ref column if missing
if err := migrateExternalRefColumn(db); err != nil {
return nil, fmt.Errorf("failed to migrate external_ref column: %w", err)
}
return &SQLiteStorage{ return &SQLiteStorage{
db: db, db: db,
}, nil }, nil
@@ -155,6 +160,47 @@ func migrateIssueCountersTable(db *sql.DB) error {
return nil return nil
} }
// migrateExternalRefColumn checks if the external_ref column exists and adds it if missing.
// This ensures existing databases created before the external reference feature get migrated automatically.
func migrateExternalRefColumn(db *sql.DB) error {
// Check if external_ref column exists
var columnExists bool
rows, err := db.Query("PRAGMA table_info(issues)")
if err != nil {
return fmt.Errorf("failed to check schema: %w", err)
}
defer rows.Close()
for rows.Next() {
var cid int
var name, typ string
var notnull, pk int
var dflt *string
err := rows.Scan(&cid, &name, &typ, &notnull, &dflt, &pk)
if err != nil {
return fmt.Errorf("failed to scan column info: %w", err)
}
if name == "external_ref" {
columnExists = true
break
}
}
if err := rows.Err(); err != nil {
return fmt.Errorf("error reading column info: %w", err)
}
if !columnExists {
// Add external_ref column
_, err := db.Exec(`ALTER TABLE issues ADD COLUMN external_ref TEXT`)
if err != nil {
return fmt.Errorf("failed to add external_ref column: %w", err)
}
}
return nil
}
// getNextIDForPrefix atomically generates the next ID for a given prefix // getNextIDForPrefix atomically generates the next ID for a given prefix
// Uses the issue_counters table for atomic, cross-process ID generation // Uses the issue_counters table for atomic, cross-process ID generation
func (s *SQLiteStorage) getNextIDForPrefix(ctx context.Context, prefix string) (int, error) { func (s *SQLiteStorage) getNextIDForPrefix(ctx context.Context, prefix string) (int, error) {
@@ -277,13 +323,14 @@ func (s *SQLiteStorage) CreateIssue(ctx context.Context, issue *types.Issue, act
INSERT INTO issues ( INSERT INTO issues (
id, title, description, design, acceptance_criteria, notes, id, title, description, design, acceptance_criteria, notes,
status, priority, issue_type, assignee, estimated_minutes, status, priority, issue_type, assignee, estimated_minutes,
created_at, updated_at created_at, updated_at, external_ref
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, `,
issue.ID, issue.Title, issue.Description, issue.Design, issue.ID, issue.Title, issue.Description, issue.Design,
issue.AcceptanceCriteria, issue.Notes, issue.Status, issue.AcceptanceCriteria, issue.Notes, issue.Status,
issue.Priority, issue.IssueType, issue.Assignee, issue.Priority, issue.IssueType, issue.Assignee,
issue.EstimatedMinutes, issue.CreatedAt, issue.UpdatedAt, issue.EstimatedMinutes, issue.CreatedAt, issue.UpdatedAt,
issue.ExternalRef,
) )
if err != nil { if err != nil {
return fmt.Errorf("failed to insert issue: %w", err) return fmt.Errorf("failed to insert issue: %w", err)
@@ -323,18 +370,19 @@ func (s *SQLiteStorage) GetIssue(ctx context.Context, id string) (*types.Issue,
var closedAt sql.NullTime var closedAt sql.NullTime
var estimatedMinutes sql.NullInt64 var estimatedMinutes sql.NullInt64
var assignee sql.NullString var assignee sql.NullString
var externalRef sql.NullString
err := s.db.QueryRowContext(ctx, ` err := s.db.QueryRowContext(ctx, `
SELECT id, title, description, design, acceptance_criteria, notes, SELECT id, title, description, design, acceptance_criteria, notes,
status, priority, issue_type, assignee, estimated_minutes, status, priority, issue_type, assignee, estimated_minutes,
created_at, updated_at, closed_at created_at, updated_at, closed_at, external_ref
FROM issues FROM issues
WHERE id = ? WHERE id = ?
`, id).Scan( `, id).Scan(
&issue.ID, &issue.Title, &issue.Description, &issue.Design, &issue.ID, &issue.Title, &issue.Description, &issue.Design,
&issue.AcceptanceCriteria, &issue.Notes, &issue.Status, &issue.AcceptanceCriteria, &issue.Notes, &issue.Status,
&issue.Priority, &issue.IssueType, &assignee, &estimatedMinutes, &issue.Priority, &issue.IssueType, &assignee, &estimatedMinutes,
&issue.CreatedAt, &issue.UpdatedAt, &closedAt, &issue.CreatedAt, &issue.UpdatedAt, &closedAt, &externalRef,
) )
if err == sql.ErrNoRows { if err == sql.ErrNoRows {
@@ -354,6 +402,9 @@ func (s *SQLiteStorage) GetIssue(ctx context.Context, id string) (*types.Issue,
if assignee.Valid { if assignee.Valid {
issue.Assignee = assignee.String issue.Assignee = assignee.String
} }
if externalRef.Valid {
issue.ExternalRef = &externalRef.String
}
return &issue, nil return &issue, nil
} }
@@ -370,6 +421,7 @@ var allowedUpdateFields = map[string]bool{
"notes": true, "notes": true,
"issue_type": true, "issue_type": true,
"estimated_minutes": true, "estimated_minutes": true,
"external_ref": true,
} }
// UpdateIssue updates fields on an issue // UpdateIssue updates fields on an issue
@@ -575,7 +627,7 @@ func (s *SQLiteStorage) SearchIssues(ctx context.Context, query string, filter t
querySQL := fmt.Sprintf(` querySQL := fmt.Sprintf(`
SELECT id, title, description, design, acceptance_criteria, notes, SELECT id, title, description, design, acceptance_criteria, notes,
status, priority, issue_type, assignee, estimated_minutes, status, priority, issue_type, assignee, estimated_minutes,
created_at, updated_at, closed_at created_at, updated_at, closed_at, external_ref
FROM issues FROM issues
%s %s
ORDER BY priority ASC, created_at DESC ORDER BY priority ASC, created_at DESC

View File

@@ -22,6 +22,7 @@ type Issue struct {
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"` UpdatedAt time.Time `json:"updated_at"`
ClosedAt *time.Time `json:"closed_at,omitempty"` ClosedAt *time.Time `json:"closed_at,omitempty"`
ExternalRef *string `json:"external_ref,omitempty"` // e.g., "gh-9", "jira-ABC"
Dependencies []*Dependency `json:"dependencies,omitempty"` // Populated only for export/import Dependencies []*Dependency `json:"dependencies,omitempty"` // Populated only for export/import
} }