fix: code review fixes for Transaction API and Search (epic bd-8bq)
- Add safe type assertions in applyUpdatesToIssue (bd-4gs) - Add --sort and --reverse flags to bd search (bd-4f6) - Add test cases for SearchIssues priority range, date range, IDs (bd-ew5) - Handle errors from GetLabelsForIssues in search.go (bd-lce) - Standardize error wrapping to fmt.Errorf pattern (bd-7kl) - Extract shared scanIssueRow helper function (bd-ajf) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -27,7 +27,9 @@ Examples:
|
|||||||
bd search "bd-5q" # Search by partial ID
|
bd search "bd-5q" # Search by partial ID
|
||||||
bd search "security" --priority-min 0 --priority-max 2
|
bd search "security" --priority-min 0 --priority-max 2
|
||||||
bd search "bug" --created-after 2025-01-01
|
bd search "bug" --created-after 2025-01-01
|
||||||
bd search "refactor" --updated-after 2025-01-01 --priority-min 1`,
|
bd search "refactor" --updated-after 2025-01-01 --priority-min 1
|
||||||
|
bd search "bug" --sort priority
|
||||||
|
bd search "task" --sort created --reverse`,
|
||||||
Run: func(cmd *cobra.Command, args []string) {
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
// Get query from args or --query flag
|
// Get query from args or --query flag
|
||||||
queryFlag, _ := cmd.Flags().GetString("query")
|
queryFlag, _ := cmd.Flags().GetString("query")
|
||||||
@@ -70,6 +72,10 @@ Examples:
|
|||||||
priorityMinStr, _ := cmd.Flags().GetString("priority-min")
|
priorityMinStr, _ := cmd.Flags().GetString("priority-min")
|
||||||
priorityMaxStr, _ := cmd.Flags().GetString("priority-max")
|
priorityMaxStr, _ := cmd.Flags().GetString("priority-max")
|
||||||
|
|
||||||
|
// Sort flags
|
||||||
|
sortBy, _ := cmd.Flags().GetString("sort")
|
||||||
|
reverse, _ := cmd.Flags().GetBool("reverse")
|
||||||
|
|
||||||
// Normalize labels
|
// Normalize labels
|
||||||
labels = util.NormalizeLabels(labels)
|
labels = util.NormalizeLabels(labels)
|
||||||
labelsAny = util.NormalizeLabels(labelsAny)
|
labelsAny = util.NormalizeLabels(labelsAny)
|
||||||
@@ -279,8 +285,16 @@ Examples:
|
|||||||
for i, issue := range issues {
|
for i, issue := range issues {
|
||||||
issueIDs[i] = issue.ID
|
issueIDs[i] = issue.ID
|
||||||
}
|
}
|
||||||
labelsMap, _ := store.GetLabelsForIssues(ctx, issueIDs)
|
labelsMap, err := store.GetLabelsForIssues(ctx, issueIDs)
|
||||||
depCounts, _ := store.GetDependencyCounts(ctx, issueIDs)
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Warning: failed to get labels: %v\n", err)
|
||||||
|
labelsMap = make(map[string][]string)
|
||||||
|
}
|
||||||
|
depCounts, err := store.GetDependencyCounts(ctx, issueIDs)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Warning: failed to get dependency counts: %v\n", err)
|
||||||
|
depCounts = make(map[string]*types.DependencyCounts)
|
||||||
|
}
|
||||||
|
|
||||||
// Populate labels
|
// Populate labels
|
||||||
for _, issue := range issues {
|
for _, issue := range issues {
|
||||||
@@ -382,5 +396,9 @@ func init() {
|
|||||||
searchCmd.Flags().String("priority-min", "", "Filter by minimum priority (inclusive, 0-4 or P0-P4)")
|
searchCmd.Flags().String("priority-min", "", "Filter by minimum priority (inclusive, 0-4 or P0-P4)")
|
||||||
searchCmd.Flags().String("priority-max", "", "Filter by maximum priority (inclusive, 0-4 or P0-P4)")
|
searchCmd.Flags().String("priority-max", "", "Filter by maximum priority (inclusive, 0-4 or P0-P4)")
|
||||||
|
|
||||||
|
// Sort flags
|
||||||
|
searchCmd.Flags().String("sort", "", "Sort by field: priority, created, updated, closed, status, id, title, type, assignee")
|
||||||
|
searchCmd.Flags().BoolP("reverse", "r", false, "Reverse sort order")
|
||||||
|
|
||||||
rootCmd.AddCommand(searchCmd)
|
rootCmd.AddCommand(searchCmd)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -120,13 +121,13 @@ func (t *sqliteTxStorage) CreateIssue(ctx context.Context, issue *types.Issue, a
|
|||||||
// Generate hash-based ID with adaptive length based on database size (bd-ea2a13)
|
// Generate hash-based ID with adaptive length based on database size (bd-ea2a13)
|
||||||
generatedID, err := GenerateIssueID(ctx, t.conn, prefix, issue, actor)
|
generatedID, err := GenerateIssueID(ctx, t.conn, prefix, issue, actor)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return wrapDBError("generate issue ID", err)
|
return fmt.Errorf("failed to generate issue ID: %w", err)
|
||||||
}
|
}
|
||||||
issue.ID = generatedID
|
issue.ID = generatedID
|
||||||
} else {
|
} else {
|
||||||
// Validate that explicitly provided ID matches the configured prefix (bd-177)
|
// Validate that explicitly provided ID matches the configured prefix (bd-177)
|
||||||
if err := ValidateIssueIDPrefix(issue.ID, prefix); err != nil {
|
if err := ValidateIssueIDPrefix(issue.ID, prefix); err != nil {
|
||||||
return wrapDBError("validate issue ID prefix", err)
|
return fmt.Errorf("failed to validate issue ID prefix: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// For hierarchical IDs (bd-a3f8e9.1), ensure parent exists
|
// For hierarchical IDs (bd-a3f8e9.1), ensure parent exists
|
||||||
@@ -147,17 +148,17 @@ func (t *sqliteTxStorage) CreateIssue(ctx context.Context, issue *types.Issue, a
|
|||||||
|
|
||||||
// Insert issue
|
// Insert issue
|
||||||
if err := insertIssue(ctx, t.conn, issue); err != nil {
|
if err := insertIssue(ctx, t.conn, issue); err != nil {
|
||||||
return wrapDBError("insert issue", err)
|
return fmt.Errorf("failed to insert issue: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Record creation event
|
// Record creation event
|
||||||
if err := recordCreatedEvent(ctx, t.conn, issue, actor); err != nil {
|
if err := recordCreatedEvent(ctx, t.conn, issue, actor); err != nil {
|
||||||
return wrapDBError("record creation event", err)
|
return fmt.Errorf("failed to record creation event: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mark issue as dirty for incremental export
|
// Mark issue as dirty for incremental export
|
||||||
if err := markDirty(ctx, t.conn, issue.ID); err != nil {
|
if err := markDirty(ctx, t.conn, issue.ID); err != nil {
|
||||||
return wrapDBError("mark issue dirty", err)
|
return fmt.Errorf("failed to mark issue dirty: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
@@ -196,12 +197,12 @@ func (t *sqliteTxStorage) CreateIssues(ctx context.Context, issues []*types.Issu
|
|||||||
if issue.ID == "" {
|
if issue.ID == "" {
|
||||||
generatedID, err := GenerateIssueID(ctx, t.conn, prefix, issue, actor)
|
generatedID, err := GenerateIssueID(ctx, t.conn, prefix, issue, actor)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return wrapDBError("generate issue ID", err)
|
return fmt.Errorf("failed to generate issue ID: %w", err)
|
||||||
}
|
}
|
||||||
issue.ID = generatedID
|
issue.ID = generatedID
|
||||||
} else {
|
} else {
|
||||||
if err := ValidateIssueIDPrefix(issue.ID, prefix); err != nil {
|
if err := ValidateIssueIDPrefix(issue.ID, prefix); err != nil {
|
||||||
return wrapDBError("validate issue ID prefix", err)
|
return fmt.Errorf("failed to validate issue ID prefix: %w", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -217,17 +218,17 @@ func (t *sqliteTxStorage) CreateIssues(ctx context.Context, issues []*types.Issu
|
|||||||
|
|
||||||
// Insert all issues
|
// Insert all issues
|
||||||
if err := insertIssues(ctx, t.conn, issues); err != nil {
|
if err := insertIssues(ctx, t.conn, issues); err != nil {
|
||||||
return wrapDBError("insert issues", err)
|
return fmt.Errorf("failed to insert issues: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Record creation events
|
// Record creation events
|
||||||
if err := recordCreatedEvents(ctx, t.conn, issues, actor); err != nil {
|
if err := recordCreatedEvents(ctx, t.conn, issues, actor); err != nil {
|
||||||
return wrapDBError("record creation events", err)
|
return fmt.Errorf("failed to record creation events: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mark all issues as dirty
|
// Mark all issues as dirty
|
||||||
if err := markDirtyBatch(ctx, t.conn, issues); err != nil {
|
if err := markDirtyBatch(ctx, t.conn, issues); err != nil {
|
||||||
return wrapDBError("mark issues dirty", err)
|
return fmt.Errorf("failed to mark issues dirty: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
@@ -236,68 +237,23 @@ func (t *sqliteTxStorage) CreateIssues(ctx context.Context, issues []*types.Issu
|
|||||||
// GetIssue retrieves an issue within the transaction.
|
// GetIssue retrieves an issue within the transaction.
|
||||||
// This enables read-your-writes semantics within the transaction.
|
// This enables read-your-writes semantics within the transaction.
|
||||||
func (t *sqliteTxStorage) GetIssue(ctx context.Context, id string) (*types.Issue, error) {
|
func (t *sqliteTxStorage) GetIssue(ctx context.Context, id string) (*types.Issue, error) {
|
||||||
var issue types.Issue
|
row := t.conn.QueryRowContext(ctx, `
|
||||||
var closedAt sql.NullTime
|
|
||||||
var estimatedMinutes sql.NullInt64
|
|
||||||
var assignee sql.NullString
|
|
||||||
var externalRef sql.NullString
|
|
||||||
var compactedAt sql.NullTime
|
|
||||||
var originalSize sql.NullInt64
|
|
||||||
var sourceRepo sql.NullString
|
|
||||||
var contentHash sql.NullString
|
|
||||||
var compactedAtCommit sql.NullString
|
|
||||||
|
|
||||||
err := t.conn.QueryRowContext(ctx, `
|
|
||||||
SELECT id, content_hash, title, description, design, acceptance_criteria, notes,
|
SELECT id, content_hash, 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, external_ref,
|
created_at, updated_at, closed_at, external_ref,
|
||||||
compaction_level, compacted_at, compacted_at_commit, original_size, source_repo
|
compaction_level, compacted_at, compacted_at_commit, original_size, source_repo
|
||||||
FROM issues
|
FROM issues
|
||||||
WHERE id = ?
|
WHERE id = ?
|
||||||
`, id).Scan(
|
`, id)
|
||||||
&issue.ID, &contentHash, &issue.Title, &issue.Description, &issue.Design,
|
|
||||||
&issue.AcceptanceCriteria, &issue.Notes, &issue.Status,
|
|
||||||
&issue.Priority, &issue.IssueType, &assignee, &estimatedMinutes,
|
|
||||||
&issue.CreatedAt, &issue.UpdatedAt, &closedAt, &externalRef,
|
|
||||||
&issue.CompactionLevel, &compactedAt, &compactedAtCommit, &originalSize, &sourceRepo,
|
|
||||||
)
|
|
||||||
|
|
||||||
if err == sql.ErrNoRows {
|
issue, err := scanIssueRow(row)
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
return nil, fmt.Errorf("failed to get issue: %w", err)
|
return nil, fmt.Errorf("failed to get issue: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if contentHash.Valid {
|
|
||||||
issue.ContentHash = contentHash.String
|
|
||||||
}
|
|
||||||
if closedAt.Valid {
|
|
||||||
issue.ClosedAt = &closedAt.Time
|
|
||||||
}
|
|
||||||
if estimatedMinutes.Valid {
|
|
||||||
mins := int(estimatedMinutes.Int64)
|
|
||||||
issue.EstimatedMinutes = &mins
|
|
||||||
}
|
|
||||||
if assignee.Valid {
|
|
||||||
issue.Assignee = assignee.String
|
|
||||||
}
|
|
||||||
if externalRef.Valid {
|
|
||||||
issue.ExternalRef = &externalRef.String
|
|
||||||
}
|
|
||||||
if compactedAt.Valid {
|
|
||||||
issue.CompactedAt = &compactedAt.Time
|
|
||||||
}
|
|
||||||
if compactedAtCommit.Valid {
|
|
||||||
issue.CompactedAtCommit = &compactedAtCommit.String
|
|
||||||
}
|
|
||||||
if originalSize.Valid {
|
|
||||||
issue.OriginalSize = int(originalSize.Int64)
|
|
||||||
}
|
|
||||||
if sourceRepo.Valid {
|
|
||||||
issue.SourceRepo = sourceRepo.String
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch labels for this issue using the transaction connection
|
// Fetch labels for this issue using the transaction connection
|
||||||
labels, err := t.getLabels(ctx, issue.ID)
|
labels, err := t.getLabels(ctx, issue.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -305,7 +261,7 @@ func (t *sqliteTxStorage) GetIssue(ctx context.Context, id string) (*types.Issue
|
|||||||
}
|
}
|
||||||
issue.Labels = labels
|
issue.Labels = labels
|
||||||
|
|
||||||
return &issue, nil
|
return issue, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// getLabels retrieves labels using the transaction's connection
|
// getLabels retrieves labels using the transaction's connection
|
||||||
@@ -335,7 +291,7 @@ func (t *sqliteTxStorage) UpdateIssue(ctx context.Context, id string, updates ma
|
|||||||
// Get old issue for event
|
// Get old issue for event
|
||||||
oldIssue, err := t.GetIssue(ctx, id)
|
oldIssue, err := t.GetIssue(ctx, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return wrapDBError("get issue for update", err)
|
return fmt.Errorf("failed to get issue for update: %w", err)
|
||||||
}
|
}
|
||||||
if oldIssue == nil {
|
if oldIssue == nil {
|
||||||
return fmt.Errorf("issue %s not found", id)
|
return fmt.Errorf("issue %s not found", id)
|
||||||
@@ -353,7 +309,7 @@ func (t *sqliteTxStorage) UpdateIssue(ctx context.Context, id string, updates ma
|
|||||||
|
|
||||||
// Validate field values
|
// Validate field values
|
||||||
if err := validateFieldUpdate(key, value); err != nil {
|
if err := validateFieldUpdate(key, value); err != nil {
|
||||||
return wrapDBError("validate field update", err)
|
return fmt.Errorf("failed to validate field update: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
setClauses = append(setClauses, fmt.Sprintf("%s = ?", key))
|
setClauses = append(setClauses, fmt.Sprintf("%s = ?", key))
|
||||||
@@ -430,34 +386,46 @@ func applyUpdatesToIssue(issue *types.Issue, updates map[string]interface{}) {
|
|||||||
for key, value := range updates {
|
for key, value := range updates {
|
||||||
switch key {
|
switch key {
|
||||||
case "title":
|
case "title":
|
||||||
issue.Title = value.(string)
|
if s, ok := value.(string); ok {
|
||||||
|
issue.Title = s
|
||||||
|
}
|
||||||
case "description":
|
case "description":
|
||||||
issue.Description = value.(string)
|
if s, ok := value.(string); ok {
|
||||||
|
issue.Description = s
|
||||||
|
}
|
||||||
case "design":
|
case "design":
|
||||||
issue.Design = value.(string)
|
if s, ok := value.(string); ok {
|
||||||
|
issue.Design = s
|
||||||
|
}
|
||||||
case "acceptance_criteria":
|
case "acceptance_criteria":
|
||||||
issue.AcceptanceCriteria = value.(string)
|
if s, ok := value.(string); ok {
|
||||||
|
issue.AcceptanceCriteria = s
|
||||||
|
}
|
||||||
case "notes":
|
case "notes":
|
||||||
issue.Notes = value.(string)
|
if s, ok := value.(string); ok {
|
||||||
|
issue.Notes = s
|
||||||
|
}
|
||||||
case "status":
|
case "status":
|
||||||
if s, ok := value.(types.Status); ok {
|
if s, ok := value.(types.Status); ok {
|
||||||
issue.Status = s
|
issue.Status = s
|
||||||
} else {
|
} else if s, ok := value.(string); ok {
|
||||||
issue.Status = types.Status(value.(string))
|
issue.Status = types.Status(s)
|
||||||
}
|
}
|
||||||
case "priority":
|
case "priority":
|
||||||
issue.Priority = value.(int)
|
if p, ok := value.(int); ok {
|
||||||
|
issue.Priority = p
|
||||||
|
}
|
||||||
case "issue_type":
|
case "issue_type":
|
||||||
if t, ok := value.(types.IssueType); ok {
|
if t, ok := value.(types.IssueType); ok {
|
||||||
issue.IssueType = t
|
issue.IssueType = t
|
||||||
} else {
|
} else if s, ok := value.(string); ok {
|
||||||
issue.IssueType = types.IssueType(value.(string))
|
issue.IssueType = types.IssueType(s)
|
||||||
}
|
}
|
||||||
case "assignee":
|
case "assignee":
|
||||||
if value == nil {
|
if value == nil {
|
||||||
issue.Assignee = ""
|
issue.Assignee = ""
|
||||||
} else {
|
} else if s, ok := value.(string); ok {
|
||||||
issue.Assignee = value.(string)
|
issue.Assignee = s
|
||||||
}
|
}
|
||||||
case "external_ref":
|
case "external_ref":
|
||||||
if value == nil {
|
if value == nil {
|
||||||
@@ -1027,7 +995,8 @@ func (t *sqliteTxStorage) SearchIssues(ctx context.Context, query string, filter
|
|||||||
querySQL := fmt.Sprintf(`
|
querySQL := fmt.Sprintf(`
|
||||||
SELECT id, content_hash, title, description, design, acceptance_criteria, notes,
|
SELECT id, content_hash, 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, external_ref, source_repo
|
created_at, updated_at, closed_at, external_ref,
|
||||||
|
compaction_level, compacted_at, compacted_at_commit, original_size, source_repo
|
||||||
FROM issues
|
FROM issues
|
||||||
%s
|
%s
|
||||||
ORDER BY priority ASC, created_at DESC
|
ORDER BY priority ASC, created_at DESC
|
||||||
@@ -1043,6 +1012,69 @@ func (t *sqliteTxStorage) SearchIssues(ctx context.Context, query string, filter
|
|||||||
return t.scanIssues(ctx, rows)
|
return t.scanIssues(ctx, rows)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// scanner is an interface that both *sql.Row and *sql.Rows satisfy
|
||||||
|
type scanner interface {
|
||||||
|
Scan(dest ...interface{}) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// scanIssueRow scans a single issue row from the database.
|
||||||
|
// This is a shared helper used by both GetIssue and SearchIssues to ensure
|
||||||
|
// consistent scanning of issue rows.
|
||||||
|
func scanIssueRow(row scanner) (*types.Issue, error) {
|
||||||
|
var issue types.Issue
|
||||||
|
var contentHash sql.NullString
|
||||||
|
var closedAt sql.NullTime
|
||||||
|
var estimatedMinutes sql.NullInt64
|
||||||
|
var assignee sql.NullString
|
||||||
|
var externalRef sql.NullString
|
||||||
|
var compactedAt sql.NullTime
|
||||||
|
var originalSize sql.NullInt64
|
||||||
|
var sourceRepo sql.NullString
|
||||||
|
var compactedAtCommit sql.NullString
|
||||||
|
|
||||||
|
err := row.Scan(
|
||||||
|
&issue.ID, &contentHash, &issue.Title, &issue.Description, &issue.Design,
|
||||||
|
&issue.AcceptanceCriteria, &issue.Notes, &issue.Status,
|
||||||
|
&issue.Priority, &issue.IssueType, &assignee, &estimatedMinutes,
|
||||||
|
&issue.CreatedAt, &issue.UpdatedAt, &closedAt, &externalRef,
|
||||||
|
&issue.CompactionLevel, &compactedAt, &compactedAtCommit, &originalSize, &sourceRepo,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to scan issue: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if contentHash.Valid {
|
||||||
|
issue.ContentHash = contentHash.String
|
||||||
|
}
|
||||||
|
if closedAt.Valid {
|
||||||
|
issue.ClosedAt = &closedAt.Time
|
||||||
|
}
|
||||||
|
if estimatedMinutes.Valid {
|
||||||
|
mins := int(estimatedMinutes.Int64)
|
||||||
|
issue.EstimatedMinutes = &mins
|
||||||
|
}
|
||||||
|
if assignee.Valid {
|
||||||
|
issue.Assignee = assignee.String
|
||||||
|
}
|
||||||
|
if externalRef.Valid {
|
||||||
|
issue.ExternalRef = &externalRef.String
|
||||||
|
}
|
||||||
|
if compactedAt.Valid {
|
||||||
|
issue.CompactedAt = &compactedAt.Time
|
||||||
|
}
|
||||||
|
if compactedAtCommit.Valid {
|
||||||
|
issue.CompactedAtCommit = &compactedAtCommit.String
|
||||||
|
}
|
||||||
|
if originalSize.Valid {
|
||||||
|
issue.OriginalSize = int(originalSize.Int64)
|
||||||
|
}
|
||||||
|
if sourceRepo.Valid {
|
||||||
|
issue.SourceRepo = sourceRepo.String
|
||||||
|
}
|
||||||
|
|
||||||
|
return &issue, nil
|
||||||
|
}
|
||||||
|
|
||||||
// scanIssues scans issue rows and fetches labels using the transaction connection.
|
// scanIssues scans issue rows and fetches labels using the transaction connection.
|
||||||
func (t *sqliteTxStorage) scanIssues(ctx context.Context, rows *sql.Rows) ([]*types.Issue, error) {
|
func (t *sqliteTxStorage) scanIssues(ctx context.Context, rows *sql.Rows) ([]*types.Issue, error) {
|
||||||
var issues []*types.Issue
|
var issues []*types.Issue
|
||||||
@@ -1050,45 +1082,11 @@ func (t *sqliteTxStorage) scanIssues(ctx context.Context, rows *sql.Rows) ([]*ty
|
|||||||
|
|
||||||
// First pass: scan all issues
|
// First pass: scan all issues
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var issue types.Issue
|
issue, err := scanIssueRow(rows)
|
||||||
var contentHash sql.NullString
|
|
||||||
var closedAt sql.NullTime
|
|
||||||
var estimatedMinutes sql.NullInt64
|
|
||||||
var assignee sql.NullString
|
|
||||||
var externalRef sql.NullString
|
|
||||||
var sourceRepo sql.NullString
|
|
||||||
|
|
||||||
err := rows.Scan(
|
|
||||||
&issue.ID, &contentHash, &issue.Title, &issue.Description, &issue.Design,
|
|
||||||
&issue.AcceptanceCriteria, &issue.Notes, &issue.Status,
|
|
||||||
&issue.Priority, &issue.IssueType, &assignee, &estimatedMinutes,
|
|
||||||
&issue.CreatedAt, &issue.UpdatedAt, &closedAt, &externalRef, &sourceRepo,
|
|
||||||
)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to scan issue: %w", err)
|
return nil, err
|
||||||
}
|
}
|
||||||
|
issues = append(issues, issue)
|
||||||
if contentHash.Valid {
|
|
||||||
issue.ContentHash = contentHash.String
|
|
||||||
}
|
|
||||||
if closedAt.Valid {
|
|
||||||
issue.ClosedAt = &closedAt.Time
|
|
||||||
}
|
|
||||||
if estimatedMinutes.Valid {
|
|
||||||
mins := int(estimatedMinutes.Int64)
|
|
||||||
issue.EstimatedMinutes = &mins
|
|
||||||
}
|
|
||||||
if assignee.Valid {
|
|
||||||
issue.Assignee = assignee.String
|
|
||||||
}
|
|
||||||
if externalRef.Valid {
|
|
||||||
issue.ExternalRef = &externalRef.String
|
|
||||||
}
|
|
||||||
if sourceRepo.Valid {
|
|
||||||
issue.SourceRepo = sourceRepo.String
|
|
||||||
}
|
|
||||||
|
|
||||||
issues = append(issues, &issue)
|
|
||||||
issueIDs = append(issueIDs, issue.ID)
|
issueIDs = append(issueIDs, issue.ID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1663,3 +1663,192 @@ func TestTransactionSearchIssuesLimit(t *testing.T) {
|
|||||||
t.Fatalf("RunInTransaction failed: %v", err)
|
t.Fatalf("RunInTransaction failed: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestTransactionSearchIssuesWithPriorityRange tests priority range filters within transaction.
|
||||||
|
func TestTransactionSearchIssuesWithPriorityRange(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
store, cleanup := setupTestDB(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
err := store.RunInTransaction(ctx, func(tx storage.Transaction) error {
|
||||||
|
// Create issues with different priorities
|
||||||
|
for i := 0; i < 5; i++ {
|
||||||
|
issue := &types.Issue{
|
||||||
|
Title: "Priority Range Test",
|
||||||
|
Status: types.StatusOpen,
|
||||||
|
Priority: i, // P0, P1, P2, P3, P4
|
||||||
|
IssueType: types.TypeTask,
|
||||||
|
}
|
||||||
|
if err := tx.CreateIssue(ctx, issue, "test-actor"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter by PriorityMin only (P2 and higher priority = lower number)
|
||||||
|
minPriority := 2
|
||||||
|
results, err := tx.SearchIssues(ctx, "", types.IssueFilter{PriorityMin: &minPriority})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// Should get P2, P3, P4
|
||||||
|
if len(results) != 3 {
|
||||||
|
t.Errorf("expected 3 issues with priority >= 2, got %d", len(results))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter by PriorityMax only
|
||||||
|
maxPriority := 1
|
||||||
|
results, err = tx.SearchIssues(ctx, "", types.IssueFilter{PriorityMax: &maxPriority})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// Should get P0, P1
|
||||||
|
if len(results) != 2 {
|
||||||
|
t.Errorf("expected 2 issues with priority <= 1, got %d", len(results))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter by priority range
|
||||||
|
minP := 1
|
||||||
|
maxP := 3
|
||||||
|
results, err = tx.SearchIssues(ctx, "", types.IssueFilter{PriorityMin: &minP, PriorityMax: &maxP})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// Should get P1, P2, P3
|
||||||
|
if len(results) != 3 {
|
||||||
|
t.Errorf("expected 3 issues with priority 1-3, got %d", len(results))
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("RunInTransaction failed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestTransactionSearchIssuesWithDateRange tests date range filters within transaction.
|
||||||
|
func TestTransactionSearchIssuesWithDateRange(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
store, cleanup := setupTestDB(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
err := store.RunInTransaction(ctx, func(tx storage.Transaction) error {
|
||||||
|
now := time.Now()
|
||||||
|
past := now.Add(-48 * time.Hour)
|
||||||
|
future := now.Add(24 * time.Hour)
|
||||||
|
|
||||||
|
// Create issues - CreatedAt is set automatically
|
||||||
|
issue1 := &types.Issue{
|
||||||
|
Title: "Recent Issue",
|
||||||
|
Status: types.StatusOpen,
|
||||||
|
Priority: 1,
|
||||||
|
IssueType: types.TypeTask,
|
||||||
|
}
|
||||||
|
if err := tx.CreateIssue(ctx, issue1, "test-actor"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter by CreatedAfter (should find the issue created just now)
|
||||||
|
createdAfter := past
|
||||||
|
results, err := tx.SearchIssues(ctx, "", types.IssueFilter{CreatedAfter: &createdAfter})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(results) != 1 {
|
||||||
|
t.Errorf("expected 1 issue created after past, got %d", len(results))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter by CreatedBefore with future time (should find the issue)
|
||||||
|
results, err = tx.SearchIssues(ctx, "", types.IssueFilter{CreatedBefore: &future})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(results) != 1 {
|
||||||
|
t.Errorf("expected 1 issue created before future, got %d", len(results))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter by CreatedBefore with past time (should find nothing)
|
||||||
|
results, err = tx.SearchIssues(ctx, "", types.IssueFilter{CreatedBefore: &past})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(results) != 0 {
|
||||||
|
t.Errorf("expected 0 issues created before past, got %d", len(results))
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("RunInTransaction failed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestTransactionSearchIssuesWithIDs tests IDs filter within transaction.
|
||||||
|
func TestTransactionSearchIssuesWithIDs(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
store, cleanup := setupTestDB(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
err := store.RunInTransaction(ctx, func(tx storage.Transaction) error {
|
||||||
|
// Create several issues and collect their IDs
|
||||||
|
var issueIDs []string
|
||||||
|
for i := 0; i < 5; i++ {
|
||||||
|
issue := &types.Issue{
|
||||||
|
Title: "IDs Filter Test",
|
||||||
|
Status: types.StatusOpen,
|
||||||
|
Priority: i,
|
||||||
|
IssueType: types.TypeTask,
|
||||||
|
}
|
||||||
|
if err := tx.CreateIssue(ctx, issue, "test-actor"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
issueIDs = append(issueIDs, issue.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter by specific IDs (first 2)
|
||||||
|
results, err := tx.SearchIssues(ctx, "", types.IssueFilter{IDs: issueIDs[:2]})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(results) != 2 {
|
||||||
|
t.Errorf("expected 2 issues when filtering by 2 IDs, got %d", len(results))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter by single ID
|
||||||
|
results, err = tx.SearchIssues(ctx, "", types.IssueFilter{IDs: []string{issueIDs[0]}})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(results) != 1 {
|
||||||
|
t.Errorf("expected 1 issue when filtering by 1 ID, got %d", len(results))
|
||||||
|
}
|
||||||
|
if results[0].ID != issueIDs[0] {
|
||||||
|
t.Errorf("expected issue ID %s, got %s", issueIDs[0], results[0].ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter by non-existent ID
|
||||||
|
results, err = tx.SearchIssues(ctx, "", types.IssueFilter{IDs: []string{"nonexistent-id"}})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(results) != 0 {
|
||||||
|
t.Errorf("expected 0 issues for non-existent ID, got %d", len(results))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Empty IDs filter should return all issues
|
||||||
|
results, err = tx.SearchIssues(ctx, "", types.IssueFilter{IDs: []string{}})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(results) != 5 {
|
||||||
|
t.Errorf("expected 5 issues with empty IDs filter, got %d", len(results))
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("RunInTransaction failed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user