diff --git a/cmd/bd/import.go b/cmd/bd/import.go index ee12b37a..da60e164 100644 --- a/cmd/bd/import.go +++ b/cmd/bd/import.go @@ -222,6 +222,13 @@ Behavior: 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 { fmt.Fprintf(os.Stderr, "Error updating issue %s: %v\n", issue.ID, err) diff --git a/cmd/bd/main.go b/cmd/bd/main.go index 6669660e..92e0c18d 100644 --- a/cmd/bd/main.go +++ b/cmd/bd/main.go @@ -238,6 +238,9 @@ func autoImportIfNewer() { if issue.EstimatedMinutes != nil { updates["estimated_minutes"] = *issue.EstimatedMinutes } + if issue.ExternalRef != nil { + updates["external_ref"] = *issue.ExternalRef + } _ = store.UpdateIssue(ctx, issue.ID, updates, "auto-import") } else { @@ -512,6 +515,7 @@ var createCmd = &cobra.Command{ assignee, _ := cmd.Flags().GetString("assignee") labels, _ := cmd.Flags().GetStringSlice("labels") explicitID, _ := cmd.Flags().GetString("id") + externalRef, _ := cmd.Flags().GetString("external-ref") // Validate explicit ID format if provided (prefix-number) if explicitID != "" { @@ -528,6 +532,11 @@ var createCmd = &cobra.Command{ } } + var externalRefPtr *string + if externalRef != "" { + externalRefPtr = &externalRef + } + issue := &types.Issue{ ID: explicitID, // Set explicit ID if provided (empty string if not) Title: title, @@ -538,6 +547,7 @@ var createCmd = &cobra.Command{ Priority: priority, IssueType: types.IssueType(issueType), Assignee: assignee, + ExternalRef: externalRefPtr, } ctx := context.Background() @@ -577,6 +587,7 @@ func init() { createCmd.Flags().StringP("assignee", "a", "", "Assignee") 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("external-ref", "", "External reference (e.g., 'gh-9', 'jira-ABC')") rootCmd.AddCommand(createCmd) } @@ -768,6 +779,10 @@ var updateCmd = &cobra.Command{ acceptanceCriteria, _ := cmd.Flags().GetString("acceptance-criteria") updates["acceptance_criteria"] = acceptanceCriteria } + if cmd.Flags().Changed("external-ref") { + externalRef, _ := cmd.Flags().GetString("external-ref") + updates["external_ref"] = externalRef + } if len(updates) == 0 { fmt.Println("No updates specified") @@ -802,6 +817,7 @@ func init() { updateCmd.Flags().String("design", "", "Design notes") updateCmd.Flags().String("notes", "", "Additional notes") updateCmd.Flags().String("acceptance-criteria", "", "Acceptance criteria") + updateCmd.Flags().String("external-ref", "", "External reference (e.g., 'gh-9', 'jira-ABC')") rootCmd.AddCommand(updateCmd) } diff --git a/internal/storage/sqlite/dependencies.go b/internal/storage/sqlite/dependencies.go index b2069ce2..8cdfe1c5 100644 --- a/internal/storage/sqlite/dependencies.go +++ b/internal/storage/sqlite/dependencies.go @@ -156,7 +156,7 @@ func (s *SQLiteStorage) GetDependencies(ctx context.Context, issueID string) ([] rows, err := s.db.QueryContext(ctx, ` 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.created_at, i.updated_at, i.closed_at + i.created_at, i.updated_at, i.closed_at, i.external_ref FROM issues i JOIN dependencies d ON i.id = d.depends_on_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, ` 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.created_at, i.updated_at, i.closed_at + i.created_at, i.updated_at, i.closed_at, i.external_ref FROM issues i JOIN dependencies d ON i.id = d.issue_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.acceptance_criteria, i.notes, i.issue_type, i.assignee, i.estimated_minutes, i.created_at, i.updated_at, i.closed_at, + i.external_ref, 0 as depth FROM issues i 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.acceptance_criteria, i.notes, i.issue_type, i.assignee, i.estimated_minutes, i.created_at, i.updated_at, i.closed_at, + i.external_ref, t.depth + 1 FROM issues i 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 estimatedMinutes sql.NullInt64 var assignee sql.NullString + var externalRef sql.NullString err := rows.Scan( &node.ID, &node.Title, &node.Status, &node.Priority, &node.Description, &node.Design, &node.AcceptanceCriteria, &node.Notes, &node.IssueType, &assignee, &estimatedMinutes, - &node.CreatedAt, &node.UpdatedAt, &closedAt, &node.Depth, + &node.CreatedAt, &node.UpdatedAt, &closedAt, &externalRef, &node.Depth, ) if err != nil { 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 { node.Assignee = assignee.String } + if externalRef.Valid { + node.ExternalRef = &externalRef.String + } node.Truncated = node.Depth == maxDepth @@ -415,12 +421,13 @@ func scanIssues(rows *sql.Rows) ([]*types.Issue, error) { var closedAt sql.NullTime var estimatedMinutes sql.NullInt64 var assignee sql.NullString + var externalRef sql.NullString err := rows.Scan( &issue.ID, &issue.Title, &issue.Description, &issue.Design, &issue.AcceptanceCriteria, &issue.Notes, &issue.Status, &issue.Priority, &issue.IssueType, &assignee, &estimatedMinutes, - &issue.CreatedAt, &issue.UpdatedAt, &closedAt, + &issue.CreatedAt, &issue.UpdatedAt, &closedAt, &externalRef, ) if err != nil { 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 { issue.Assignee = assignee.String } + if externalRef.Valid { + issue.ExternalRef = &externalRef.String + } issues = append(issues, &issue) } diff --git a/internal/storage/sqlite/labels.go b/internal/storage/sqlite/labels.go index 861e1cbb..7f0a3083 100644 --- a/internal/storage/sqlite/labels.go +++ b/internal/storage/sqlite/labels.go @@ -108,7 +108,7 @@ func (s *SQLiteStorage) GetIssuesByLabel(ctx context.Context, label string) ([]* rows, err := s.db.QueryContext(ctx, ` 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.created_at, i.updated_at, i.closed_at + i.created_at, i.updated_at, i.closed_at, i.external_ref FROM issues i JOIN labels l ON i.id = l.issue_id WHERE l.label = ? diff --git a/internal/storage/sqlite/ready.go b/internal/storage/sqlite/ready.go index bd4a5fb8..074d7a81 100644 --- a/internal/storage/sqlite/ready.go +++ b/internal/storage/sqlite/ready.go @@ -46,7 +46,7 @@ func (s *SQLiteStorage) GetReadyWork(ctx context.Context, filter types.WorkFilte query := fmt.Sprintf(` 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.created_at, i.updated_at, i.closed_at + i.created_at, i.updated_at, i.closed_at, i.external_ref FROM issues i WHERE %s AND NOT EXISTS ( @@ -76,7 +76,7 @@ func (s *SQLiteStorage) GetBlockedIssues(ctx context.Context) ([]*types.BlockedI 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.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, GROUP_CONCAT(d.depends_on_id, ',') as blocker_ids FROM issues i @@ -99,13 +99,14 @@ func (s *SQLiteStorage) GetBlockedIssues(ctx context.Context) ([]*types.BlockedI var closedAt sql.NullTime var estimatedMinutes sql.NullInt64 var assignee sql.NullString + var externalRef sql.NullString var blockerIDsStr string err := rows.Scan( &issue.ID, &issue.Title, &issue.Description, &issue.Design, &issue.AcceptanceCriteria, &issue.Notes, &issue.Status, &issue.Priority, &issue.IssueType, &assignee, &estimatedMinutes, - &issue.CreatedAt, &issue.UpdatedAt, &closedAt, &issue.BlockedByCount, + &issue.CreatedAt, &issue.UpdatedAt, &closedAt, &externalRef, &issue.BlockedByCount, &blockerIDsStr, ) if err != nil { @@ -122,6 +123,9 @@ func (s *SQLiteStorage) GetBlockedIssues(ctx context.Context) ([]*types.BlockedI if assignee.Valid { issue.Assignee = assignee.String } + if externalRef.Valid { + issue.ExternalRef = &externalRef.String + } // Parse comma-separated blocker IDs if blockerIDsStr != "" { diff --git a/internal/storage/sqlite/schema.go b/internal/storage/sqlite/schema.go index f05de236..90eab4f5 100644 --- a/internal/storage/sqlite/schema.go +++ b/internal/storage/sqlite/schema.go @@ -16,7 +16,8 @@ CREATE TABLE IF NOT EXISTS issues ( estimated_minutes INTEGER, created_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); diff --git a/internal/storage/sqlite/sqlite.go b/internal/storage/sqlite/sqlite.go index 637928c3..b35ea233 100644 --- a/internal/storage/sqlite/sqlite.go +++ b/internal/storage/sqlite/sqlite.go @@ -55,6 +55,11 @@ func New(path string) (*SQLiteStorage, error) { 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{ db: db, }, nil @@ -155,6 +160,47 @@ func migrateIssueCountersTable(db *sql.DB) error { 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, ¬null, &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 // Uses the issue_counters table for atomic, cross-process ID generation 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 ( id, title, description, design, acceptance_criteria, notes, status, priority, issue_type, assignee, estimated_minutes, - created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + created_at, updated_at, external_ref + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `, issue.ID, issue.Title, issue.Description, issue.Design, issue.AcceptanceCriteria, issue.Notes, issue.Status, issue.Priority, issue.IssueType, issue.Assignee, issue.EstimatedMinutes, issue.CreatedAt, issue.UpdatedAt, + issue.ExternalRef, ) if err != nil { 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 estimatedMinutes sql.NullInt64 var assignee sql.NullString + var externalRef sql.NullString err := s.db.QueryRowContext(ctx, ` SELECT id, title, description, design, acceptance_criteria, notes, status, priority, issue_type, assignee, estimated_minutes, - created_at, updated_at, closed_at + created_at, updated_at, closed_at, external_ref FROM issues WHERE id = ? `, id).Scan( &issue.ID, &issue.Title, &issue.Description, &issue.Design, &issue.AcceptanceCriteria, &issue.Notes, &issue.Status, &issue.Priority, &issue.IssueType, &assignee, &estimatedMinutes, - &issue.CreatedAt, &issue.UpdatedAt, &closedAt, + &issue.CreatedAt, &issue.UpdatedAt, &closedAt, &externalRef, ) if err == sql.ErrNoRows { @@ -354,6 +402,9 @@ func (s *SQLiteStorage) GetIssue(ctx context.Context, id string) (*types.Issue, if assignee.Valid { issue.Assignee = assignee.String } + if externalRef.Valid { + issue.ExternalRef = &externalRef.String + } return &issue, nil } @@ -370,6 +421,7 @@ var allowedUpdateFields = map[string]bool{ "notes": true, "issue_type": true, "estimated_minutes": true, + "external_ref": true, } // UpdateIssue updates fields on an issue @@ -575,7 +627,7 @@ func (s *SQLiteStorage) SearchIssues(ctx context.Context, query string, filter t querySQL := fmt.Sprintf(` SELECT id, title, description, design, acceptance_criteria, notes, status, priority, issue_type, assignee, estimated_minutes, - created_at, updated_at, closed_at + created_at, updated_at, closed_at, external_ref FROM issues %s ORDER BY priority ASC, created_at DESC diff --git a/internal/types/types.go b/internal/types/types.go index 8c80054d..0d59c724 100644 --- a/internal/types/types.go +++ b/internal/types/types.go @@ -22,6 +22,7 @@ type Issue struct { CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` 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 }