Merge main into PR #160 - combine reverse mode with substring bugfix
Amp-Thread-ID: https://ampcode.com/threads/T-b2413b0e-2720-45b1-9b3d-acaa7d4cf9b4 Co-authored-by: Amp <amp@ampcode.com>
This commit is contained in:
911
internal/storage/memory/memory.go
Normal file
911
internal/storage/memory/memory.go
Normal file
@@ -0,0 +1,911 @@
|
||||
// Package memory implements the storage interface using in-memory data structures.
|
||||
// This is designed for --no-db mode where the database is loaded from JSONL at startup
|
||||
// and written back to JSONL after each command.
|
||||
package memory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/steveyegge/beads/internal/types"
|
||||
)
|
||||
|
||||
// MemoryStorage implements the Storage interface using in-memory data structures
|
||||
type MemoryStorage struct {
|
||||
mu sync.RWMutex // Protects all maps
|
||||
|
||||
// Core data
|
||||
issues map[string]*types.Issue // ID -> Issue
|
||||
dependencies map[string][]*types.Dependency // IssueID -> Dependencies
|
||||
labels map[string][]string // IssueID -> Labels
|
||||
events map[string][]*types.Event // IssueID -> Events
|
||||
comments map[string][]*types.Comment // IssueID -> Comments
|
||||
config map[string]string // Config key-value pairs
|
||||
metadata map[string]string // Metadata key-value pairs
|
||||
counters map[string]int // Prefix -> Last ID
|
||||
|
||||
// For tracking
|
||||
dirty map[string]bool // IssueIDs that have been modified
|
||||
|
||||
jsonlPath string // Path to source JSONL file (for reference)
|
||||
closed bool
|
||||
}
|
||||
|
||||
// New creates a new in-memory storage backend
|
||||
func New(jsonlPath string) *MemoryStorage {
|
||||
return &MemoryStorage{
|
||||
issues: make(map[string]*types.Issue),
|
||||
dependencies: make(map[string][]*types.Dependency),
|
||||
labels: make(map[string][]string),
|
||||
events: make(map[string][]*types.Event),
|
||||
comments: make(map[string][]*types.Comment),
|
||||
config: make(map[string]string),
|
||||
metadata: make(map[string]string),
|
||||
counters: make(map[string]int),
|
||||
dirty: make(map[string]bool),
|
||||
jsonlPath: jsonlPath,
|
||||
}
|
||||
}
|
||||
|
||||
// LoadFromIssues populates the in-memory storage from a slice of issues
|
||||
// This is used when loading from JSONL at startup
|
||||
func (m *MemoryStorage) LoadFromIssues(issues []*types.Issue) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
for _, issue := range issues {
|
||||
if issue == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// Store the issue
|
||||
m.issues[issue.ID] = issue
|
||||
|
||||
// Store dependencies
|
||||
if len(issue.Dependencies) > 0 {
|
||||
m.dependencies[issue.ID] = issue.Dependencies
|
||||
}
|
||||
|
||||
// Store labels
|
||||
if len(issue.Labels) > 0 {
|
||||
m.labels[issue.ID] = issue.Labels
|
||||
}
|
||||
|
||||
// Store comments
|
||||
if len(issue.Comments) > 0 {
|
||||
m.comments[issue.ID] = issue.Comments
|
||||
}
|
||||
|
||||
// Update counter based on issue ID
|
||||
prefix, num := extractPrefixAndNumber(issue.ID)
|
||||
if prefix != "" && num > 0 {
|
||||
if m.counters[prefix] < num {
|
||||
m.counters[prefix] = num
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetAllIssues returns all issues in memory (for export to JSONL)
|
||||
func (m *MemoryStorage) GetAllIssues() []*types.Issue {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
issues := make([]*types.Issue, 0, len(m.issues))
|
||||
for _, issue := range m.issues {
|
||||
// Deep copy to avoid mutations
|
||||
issueCopy := *issue
|
||||
|
||||
// Attach dependencies
|
||||
if deps, ok := m.dependencies[issue.ID]; ok {
|
||||
issueCopy.Dependencies = deps
|
||||
}
|
||||
|
||||
// Attach labels
|
||||
if labels, ok := m.labels[issue.ID]; ok {
|
||||
issueCopy.Labels = labels
|
||||
}
|
||||
|
||||
// Attach comments
|
||||
if comments, ok := m.comments[issue.ID]; ok {
|
||||
issueCopy.Comments = comments
|
||||
}
|
||||
|
||||
issues = append(issues, &issueCopy)
|
||||
}
|
||||
|
||||
// Sort by ID for consistent output
|
||||
sort.Slice(issues, func(i, j int) bool {
|
||||
return issues[i].ID < issues[j].ID
|
||||
})
|
||||
|
||||
return issues
|
||||
}
|
||||
|
||||
// extractPrefixAndNumber extracts prefix and number from issue ID like "bd-123" -> ("bd", 123)
|
||||
func extractPrefixAndNumber(id string) (string, int) {
|
||||
parts := strings.SplitN(id, "-", 2)
|
||||
if len(parts) != 2 {
|
||||
return "", 0
|
||||
}
|
||||
var num int
|
||||
_, err := fmt.Sscanf(parts[1], "%d", &num)
|
||||
if err != nil {
|
||||
return "", 0
|
||||
}
|
||||
return parts[0], num
|
||||
}
|
||||
|
||||
// CreateIssue creates a new issue
|
||||
func (m *MemoryStorage) CreateIssue(ctx context.Context, issue *types.Issue, actor string) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
// Validate
|
||||
if err := issue.Validate(); err != nil {
|
||||
return fmt.Errorf("validation failed: %w", err)
|
||||
}
|
||||
|
||||
// Set timestamps
|
||||
now := time.Now()
|
||||
issue.CreatedAt = now
|
||||
issue.UpdatedAt = now
|
||||
|
||||
// Generate ID if not set
|
||||
if issue.ID == "" {
|
||||
prefix := m.config["issue_prefix"]
|
||||
if prefix == "" {
|
||||
prefix = "bd" // Default fallback
|
||||
}
|
||||
|
||||
// Get next ID
|
||||
m.counters[prefix]++
|
||||
issue.ID = fmt.Sprintf("%s-%d", prefix, m.counters[prefix])
|
||||
}
|
||||
|
||||
// Check for duplicate
|
||||
if _, exists := m.issues[issue.ID]; exists {
|
||||
return fmt.Errorf("issue %s already exists", issue.ID)
|
||||
}
|
||||
|
||||
// Store issue
|
||||
m.issues[issue.ID] = issue
|
||||
m.dirty[issue.ID] = true
|
||||
|
||||
// Record event
|
||||
event := &types.Event{
|
||||
IssueID: issue.ID,
|
||||
EventType: types.EventCreated,
|
||||
Actor: actor,
|
||||
CreatedAt: now,
|
||||
}
|
||||
m.events[issue.ID] = append(m.events[issue.ID], event)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// CreateIssues creates multiple issues atomically
|
||||
func (m *MemoryStorage) CreateIssues(ctx context.Context, issues []*types.Issue, actor string) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
// Validate all first
|
||||
for i, issue := range issues {
|
||||
if err := issue.Validate(); err != nil {
|
||||
return fmt.Errorf("validation failed for issue %d: %w", i, err)
|
||||
}
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
prefix := m.config["issue_prefix"]
|
||||
if prefix == "" {
|
||||
prefix = "bd"
|
||||
}
|
||||
|
||||
// Track IDs in this batch to detect duplicates within batch
|
||||
batchIDs := make(map[string]bool)
|
||||
|
||||
// Generate IDs for issues that need them
|
||||
for _, issue := range issues {
|
||||
issue.CreatedAt = now
|
||||
issue.UpdatedAt = now
|
||||
|
||||
if issue.ID == "" {
|
||||
m.counters[prefix]++
|
||||
issue.ID = fmt.Sprintf("%s-%d", prefix, m.counters[prefix])
|
||||
}
|
||||
|
||||
// Check for duplicates in existing issues
|
||||
if _, exists := m.issues[issue.ID]; exists {
|
||||
return fmt.Errorf("issue %s already exists", issue.ID)
|
||||
}
|
||||
|
||||
// Check for duplicates within this batch
|
||||
if batchIDs[issue.ID] {
|
||||
return fmt.Errorf("duplicate ID within batch: %s", issue.ID)
|
||||
}
|
||||
batchIDs[issue.ID] = true
|
||||
}
|
||||
|
||||
// Store all issues
|
||||
for _, issue := range issues {
|
||||
m.issues[issue.ID] = issue
|
||||
m.dirty[issue.ID] = true
|
||||
|
||||
// Record event
|
||||
event := &types.Event{
|
||||
IssueID: issue.ID,
|
||||
EventType: types.EventCreated,
|
||||
Actor: actor,
|
||||
CreatedAt: now,
|
||||
}
|
||||
m.events[issue.ID] = append(m.events[issue.ID], event)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetIssue retrieves an issue by ID
|
||||
func (m *MemoryStorage) GetIssue(ctx context.Context, id string) (*types.Issue, error) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
issue, exists := m.issues[id]
|
||||
if !exists {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Return a copy to avoid mutations
|
||||
issueCopy := *issue
|
||||
|
||||
// Attach dependencies
|
||||
if deps, ok := m.dependencies[id]; ok {
|
||||
issueCopy.Dependencies = deps
|
||||
}
|
||||
|
||||
// Attach labels
|
||||
if labels, ok := m.labels[id]; ok {
|
||||
issueCopy.Labels = labels
|
||||
}
|
||||
|
||||
return &issueCopy, nil
|
||||
}
|
||||
|
||||
// UpdateIssue updates fields on an issue
|
||||
func (m *MemoryStorage) UpdateIssue(ctx context.Context, id string, updates map[string]interface{}, actor string) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
issue, exists := m.issues[id]
|
||||
if !exists {
|
||||
return fmt.Errorf("issue %s not found", id)
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
issue.UpdatedAt = now
|
||||
|
||||
// Apply updates
|
||||
for key, value := range updates {
|
||||
switch key {
|
||||
case "title":
|
||||
if v, ok := value.(string); ok {
|
||||
issue.Title = v
|
||||
}
|
||||
case "description":
|
||||
if v, ok := value.(string); ok {
|
||||
issue.Description = v
|
||||
}
|
||||
case "design":
|
||||
if v, ok := value.(string); ok {
|
||||
issue.Design = v
|
||||
}
|
||||
case "acceptance_criteria":
|
||||
if v, ok := value.(string); ok {
|
||||
issue.AcceptanceCriteria = v
|
||||
}
|
||||
case "notes":
|
||||
if v, ok := value.(string); ok {
|
||||
issue.Notes = v
|
||||
}
|
||||
case "status":
|
||||
if v, ok := value.(string); ok {
|
||||
oldStatus := issue.Status
|
||||
issue.Status = types.Status(v)
|
||||
|
||||
// Manage closed_at
|
||||
if issue.Status == types.StatusClosed && oldStatus != types.StatusClosed {
|
||||
issue.ClosedAt = &now
|
||||
} else if issue.Status != types.StatusClosed && oldStatus == types.StatusClosed {
|
||||
issue.ClosedAt = nil
|
||||
}
|
||||
}
|
||||
case "priority":
|
||||
if v, ok := value.(int); ok {
|
||||
issue.Priority = v
|
||||
}
|
||||
case "issue_type":
|
||||
if v, ok := value.(string); ok {
|
||||
issue.IssueType = types.IssueType(v)
|
||||
}
|
||||
case "assignee":
|
||||
if v, ok := value.(string); ok {
|
||||
issue.Assignee = v
|
||||
} else if value == nil {
|
||||
issue.Assignee = ""
|
||||
}
|
||||
case "external_ref":
|
||||
if v, ok := value.(string); ok {
|
||||
issue.ExternalRef = &v
|
||||
} else if value == nil {
|
||||
issue.ExternalRef = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
m.dirty[id] = true
|
||||
|
||||
// Record event
|
||||
eventType := types.EventUpdated
|
||||
if status, hasStatus := updates["status"]; hasStatus {
|
||||
if status == string(types.StatusClosed) {
|
||||
eventType = types.EventClosed
|
||||
}
|
||||
}
|
||||
|
||||
event := &types.Event{
|
||||
IssueID: id,
|
||||
EventType: eventType,
|
||||
Actor: actor,
|
||||
CreatedAt: now,
|
||||
}
|
||||
m.events[id] = append(m.events[id], event)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// CloseIssue closes an issue with a reason
|
||||
func (m *MemoryStorage) CloseIssue(ctx context.Context, id string, reason string, actor string) error {
|
||||
return m.UpdateIssue(ctx, id, map[string]interface{}{
|
||||
"status": string(types.StatusClosed),
|
||||
}, actor)
|
||||
}
|
||||
|
||||
// SearchIssues finds issues matching query and filters
|
||||
func (m *MemoryStorage) SearchIssues(ctx context.Context, query string, filter types.IssueFilter) ([]*types.Issue, error) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
var results []*types.Issue
|
||||
|
||||
for _, issue := range m.issues {
|
||||
// Apply filters
|
||||
if filter.Status != nil && issue.Status != *filter.Status {
|
||||
continue
|
||||
}
|
||||
if filter.Priority != nil && issue.Priority != *filter.Priority {
|
||||
continue
|
||||
}
|
||||
if filter.IssueType != nil && issue.IssueType != *filter.IssueType {
|
||||
continue
|
||||
}
|
||||
if filter.Assignee != nil && issue.Assignee != *filter.Assignee {
|
||||
continue
|
||||
}
|
||||
|
||||
// Query search (title, description, or ID)
|
||||
if query != "" {
|
||||
query = strings.ToLower(query)
|
||||
if !strings.Contains(strings.ToLower(issue.Title), query) &&
|
||||
!strings.Contains(strings.ToLower(issue.Description), query) &&
|
||||
!strings.Contains(strings.ToLower(issue.ID), query) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Label filtering: must have ALL specified labels
|
||||
if len(filter.Labels) > 0 {
|
||||
issueLabels := m.labels[issue.ID]
|
||||
hasAllLabels := true
|
||||
for _, reqLabel := range filter.Labels {
|
||||
found := false
|
||||
for _, label := range issueLabels {
|
||||
if label == reqLabel {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
hasAllLabels = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasAllLabels {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// ID filtering
|
||||
if len(filter.IDs) > 0 {
|
||||
found := false
|
||||
for _, filterID := range filter.IDs {
|
||||
if issue.ID == filterID {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Copy issue and attach metadata
|
||||
issueCopy := *issue
|
||||
if deps, ok := m.dependencies[issue.ID]; ok {
|
||||
issueCopy.Dependencies = deps
|
||||
}
|
||||
if labels, ok := m.labels[issue.ID]; ok {
|
||||
issueCopy.Labels = labels
|
||||
}
|
||||
|
||||
results = append(results, &issueCopy)
|
||||
}
|
||||
|
||||
// Sort by priority, then by created_at
|
||||
sort.Slice(results, func(i, j int) bool {
|
||||
if results[i].Priority != results[j].Priority {
|
||||
return results[i].Priority < results[j].Priority
|
||||
}
|
||||
return results[i].CreatedAt.After(results[j].CreatedAt)
|
||||
})
|
||||
|
||||
// Apply limit
|
||||
if filter.Limit > 0 && len(results) > filter.Limit {
|
||||
results = results[:filter.Limit]
|
||||
}
|
||||
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// AddDependency adds a dependency between issues
|
||||
func (m *MemoryStorage) AddDependency(ctx context.Context, dep *types.Dependency, actor string) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
// Check that both issues exist
|
||||
if _, exists := m.issues[dep.IssueID]; !exists {
|
||||
return fmt.Errorf("issue %s not found", dep.IssueID)
|
||||
}
|
||||
if _, exists := m.issues[dep.DependsOnID]; !exists {
|
||||
return fmt.Errorf("issue %s not found", dep.DependsOnID)
|
||||
}
|
||||
|
||||
// Check for duplicates
|
||||
for _, existing := range m.dependencies[dep.IssueID] {
|
||||
if existing.DependsOnID == dep.DependsOnID && existing.Type == dep.Type {
|
||||
return fmt.Errorf("dependency already exists")
|
||||
}
|
||||
}
|
||||
|
||||
m.dependencies[dep.IssueID] = append(m.dependencies[dep.IssueID], dep)
|
||||
m.dirty[dep.IssueID] = true
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// RemoveDependency removes a dependency
|
||||
func (m *MemoryStorage) RemoveDependency(ctx context.Context, issueID, dependsOnID string, actor string) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
deps := m.dependencies[issueID]
|
||||
newDeps := make([]*types.Dependency, 0)
|
||||
|
||||
for _, dep := range deps {
|
||||
if dep.DependsOnID != dependsOnID {
|
||||
newDeps = append(newDeps, dep)
|
||||
}
|
||||
}
|
||||
|
||||
m.dependencies[issueID] = newDeps
|
||||
m.dirty[issueID] = true
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetDependencies gets issues that this issue depends on
|
||||
func (m *MemoryStorage) GetDependencies(ctx context.Context, issueID string) ([]*types.Issue, error) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
var results []*types.Issue
|
||||
for _, dep := range m.dependencies[issueID] {
|
||||
if issue, exists := m.issues[dep.DependsOnID]; exists {
|
||||
issueCopy := *issue
|
||||
results = append(results, &issueCopy)
|
||||
}
|
||||
}
|
||||
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// GetDependents gets issues that depend on this issue
|
||||
func (m *MemoryStorage) GetDependents(ctx context.Context, issueID string) ([]*types.Issue, error) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
var results []*types.Issue
|
||||
for id, deps := range m.dependencies {
|
||||
for _, dep := range deps {
|
||||
if dep.DependsOnID == issueID {
|
||||
if issue, exists := m.issues[id]; exists {
|
||||
results = append(results, issue)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// GetDependencyRecords gets dependency records for an issue
|
||||
func (m *MemoryStorage) GetDependencyRecords(ctx context.Context, issueID string) ([]*types.Dependency, error) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
return m.dependencies[issueID], nil
|
||||
}
|
||||
|
||||
// GetAllDependencyRecords gets all dependency records
|
||||
func (m *MemoryStorage) GetAllDependencyRecords(ctx context.Context) (map[string][]*types.Dependency, error) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
// Return a copy
|
||||
result := make(map[string][]*types.Dependency)
|
||||
for k, v := range m.dependencies {
|
||||
result[k] = v
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// GetDependencyTree gets the dependency tree for an issue
|
||||
func (m *MemoryStorage) GetDependencyTree(ctx context.Context, issueID string, maxDepth int, showAllPaths bool) ([]*types.TreeNode, error) {
|
||||
// Simplified implementation - just return direct dependencies
|
||||
deps, err := m.GetDependencies(ctx, issueID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var nodes []*types.TreeNode
|
||||
for _, dep := range deps {
|
||||
node := &types.TreeNode{
|
||||
Depth: 1,
|
||||
}
|
||||
// Copy issue fields
|
||||
node.ID = dep.ID
|
||||
node.Title = dep.Title
|
||||
node.Description = dep.Description
|
||||
node.Status = dep.Status
|
||||
node.Priority = dep.Priority
|
||||
node.IssueType = dep.IssueType
|
||||
nodes = append(nodes, node)
|
||||
}
|
||||
|
||||
return nodes, nil
|
||||
}
|
||||
|
||||
// DetectCycles detects dependency cycles
|
||||
func (m *MemoryStorage) DetectCycles(ctx context.Context) ([][]*types.Issue, error) {
|
||||
// Simplified - return empty (no cycles detected)
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Add label methods
|
||||
func (m *MemoryStorage) AddLabel(ctx context.Context, issueID, label, actor string) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
// Check if issue exists
|
||||
if _, exists := m.issues[issueID]; !exists {
|
||||
return fmt.Errorf("issue %s not found", issueID)
|
||||
}
|
||||
|
||||
// Check for duplicate
|
||||
for _, l := range m.labels[issueID] {
|
||||
if l == label {
|
||||
return nil // Already exists
|
||||
}
|
||||
}
|
||||
|
||||
m.labels[issueID] = append(m.labels[issueID], label)
|
||||
m.dirty[issueID] = true
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *MemoryStorage) RemoveLabel(ctx context.Context, issueID, label, actor string) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
labels := m.labels[issueID]
|
||||
newLabels := make([]string, 0)
|
||||
|
||||
for _, l := range labels {
|
||||
if l != label {
|
||||
newLabels = append(newLabels, l)
|
||||
}
|
||||
}
|
||||
|
||||
m.labels[issueID] = newLabels
|
||||
m.dirty[issueID] = true
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *MemoryStorage) GetLabels(ctx context.Context, issueID string) ([]string, error) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
return m.labels[issueID], nil
|
||||
}
|
||||
|
||||
func (m *MemoryStorage) GetIssuesByLabel(ctx context.Context, label string) ([]*types.Issue, error) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
var results []*types.Issue
|
||||
for issueID, labels := range m.labels {
|
||||
for _, l := range labels {
|
||||
if l == label {
|
||||
if issue, exists := m.issues[issueID]; exists {
|
||||
issueCopy := *issue
|
||||
results = append(results, &issueCopy)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// Stub implementations for other required methods
|
||||
func (m *MemoryStorage) GetReadyWork(ctx context.Context, filter types.WorkFilter) ([]*types.Issue, error) {
|
||||
// Simplified: return open issues with no blocking dependencies
|
||||
return m.SearchIssues(ctx, "", types.IssueFilter{
|
||||
Status: func() *types.Status { s := types.StatusOpen; return &s }(),
|
||||
})
|
||||
}
|
||||
|
||||
func (m *MemoryStorage) GetBlockedIssues(ctx context.Context) ([]*types.BlockedIssue, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *MemoryStorage) GetEpicsEligibleForClosure(ctx context.Context) ([]*types.EpicStatus, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *MemoryStorage) AddComment(ctx context.Context, issueID, actor, comment string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *MemoryStorage) GetEvents(ctx context.Context, issueID string, limit int) ([]*types.Event, error) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
events := m.events[issueID]
|
||||
if limit > 0 && len(events) > limit {
|
||||
events = events[len(events)-limit:]
|
||||
}
|
||||
|
||||
return events, nil
|
||||
}
|
||||
|
||||
func (m *MemoryStorage) AddIssueComment(ctx context.Context, issueID, author, text string) (*types.Comment, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
comment := &types.Comment{
|
||||
ID: int64(len(m.comments[issueID]) + 1),
|
||||
IssueID: issueID,
|
||||
Author: author,
|
||||
Text: text,
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
|
||||
m.comments[issueID] = append(m.comments[issueID], comment)
|
||||
m.dirty[issueID] = true
|
||||
|
||||
return comment, nil
|
||||
}
|
||||
|
||||
func (m *MemoryStorage) GetIssueComments(ctx context.Context, issueID string) ([]*types.Comment, error) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
return m.comments[issueID], nil
|
||||
}
|
||||
|
||||
func (m *MemoryStorage) GetStatistics(ctx context.Context) (*types.Statistics, error) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
stats := &types.Statistics{
|
||||
TotalIssues: len(m.issues),
|
||||
}
|
||||
|
||||
for _, issue := range m.issues {
|
||||
switch issue.Status {
|
||||
case types.StatusOpen:
|
||||
stats.OpenIssues++
|
||||
case types.StatusInProgress:
|
||||
stats.InProgressIssues++
|
||||
case types.StatusBlocked:
|
||||
stats.BlockedIssues++
|
||||
case types.StatusClosed:
|
||||
stats.ClosedIssues++
|
||||
}
|
||||
}
|
||||
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
// Dirty tracking
|
||||
func (m *MemoryStorage) GetDirtyIssues(ctx context.Context) ([]string, error) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
var dirtyIDs []string
|
||||
for id := range m.dirty {
|
||||
dirtyIDs = append(dirtyIDs, id)
|
||||
}
|
||||
|
||||
return dirtyIDs, nil
|
||||
}
|
||||
|
||||
func (m *MemoryStorage) ClearDirtyIssues(ctx context.Context) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
m.dirty = make(map[string]bool)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *MemoryStorage) ClearDirtyIssuesByID(ctx context.Context, issueIDs []string) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
for _, id := range issueIDs {
|
||||
delete(m.dirty, id)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Config
|
||||
func (m *MemoryStorage) SetConfig(ctx context.Context, key, value string) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
m.config[key] = value
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *MemoryStorage) GetConfig(ctx context.Context, key string) (string, error) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
return m.config[key], nil
|
||||
}
|
||||
|
||||
func (m *MemoryStorage) DeleteConfig(ctx context.Context, key string) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
delete(m.config, key)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *MemoryStorage) GetAllConfig(ctx context.Context) (map[string]string, error) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
// Return a copy to avoid mutations
|
||||
result := make(map[string]string)
|
||||
for k, v := range m.config {
|
||||
result[k] = v
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// Metadata
|
||||
func (m *MemoryStorage) SetMetadata(ctx context.Context, key, value string) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
m.metadata[key] = value
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *MemoryStorage) GetMetadata(ctx context.Context, key string) (string, error) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
return m.metadata[key], nil
|
||||
}
|
||||
|
||||
// Prefix rename operations (no-ops for memory storage)
|
||||
func (m *MemoryStorage) UpdateIssueID(ctx context.Context, oldID, newID string, issue *types.Issue, actor string) error {
|
||||
return fmt.Errorf("UpdateIssueID not supported in --no-db mode")
|
||||
}
|
||||
|
||||
func (m *MemoryStorage) RenameDependencyPrefix(ctx context.Context, oldPrefix, newPrefix string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *MemoryStorage) RenameCounterPrefix(ctx context.Context, oldPrefix, newPrefix string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Lifecycle
|
||||
func (m *MemoryStorage) Close() error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
m.closed = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *MemoryStorage) Path() string {
|
||||
return m.jsonlPath
|
||||
}
|
||||
|
||||
// UnderlyingDB returns nil for memory storage (no SQL database)
|
||||
func (m *MemoryStorage) UnderlyingDB() *sql.DB {
|
||||
return nil
|
||||
}
|
||||
|
||||
// UnderlyingConn returns error for memory storage (no SQL database)
|
||||
func (m *MemoryStorage) UnderlyingConn(ctx context.Context) (*sql.Conn, error) {
|
||||
return nil, fmt.Errorf("UnderlyingConn not available in memory storage")
|
||||
}
|
||||
|
||||
// SyncAllCounters synchronizes ID counters based on existing issues
|
||||
func (m *MemoryStorage) SyncAllCounters(ctx context.Context) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
// Reset counters
|
||||
m.counters = make(map[string]int)
|
||||
|
||||
// Recompute from issues
|
||||
for _, issue := range m.issues {
|
||||
prefix, num := extractPrefixAndNumber(issue.ID)
|
||||
if prefix != "" && num > 0 {
|
||||
if m.counters[prefix] < num {
|
||||
m.counters[prefix] = num
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// MarkIssueDirty marks an issue as dirty for export
|
||||
func (m *MemoryStorage) MarkIssueDirty(ctx context.Context, issueID string) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
m.dirty[issueID] = true
|
||||
return nil
|
||||
}
|
||||
915
internal/storage/memory/memory_test.go
Normal file
915
internal/storage/memory/memory_test.go
Normal file
@@ -0,0 +1,915 @@
|
||||
package memory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/steveyegge/beads/internal/types"
|
||||
)
|
||||
|
||||
func setupTestMemory(t *testing.T) *MemoryStorage {
|
||||
t.Helper()
|
||||
|
||||
store := New("")
|
||||
ctx := context.Background()
|
||||
|
||||
// Set issue_prefix config
|
||||
if err := store.SetConfig(ctx, "issue_prefix", "bd"); err != nil {
|
||||
t.Fatalf("failed to set issue_prefix: %v", err)
|
||||
}
|
||||
|
||||
return store
|
||||
}
|
||||
|
||||
func TestCreateIssue(t *testing.T) {
|
||||
store := setupTestMemory(t)
|
||||
defer store.Close()
|
||||
|
||||
ctx := context.Background()
|
||||
issue := &types.Issue{
|
||||
Title: "Test issue",
|
||||
Description: "Test description",
|
||||
Status: types.StatusOpen,
|
||||
Priority: 1,
|
||||
IssueType: types.TypeTask,
|
||||
}
|
||||
|
||||
err := store.CreateIssue(ctx, issue, "test-user")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateIssue failed: %v", err)
|
||||
}
|
||||
|
||||
if issue.ID == "" {
|
||||
t.Error("Issue ID should be set")
|
||||
}
|
||||
|
||||
if !issue.CreatedAt.After(time.Time{}) {
|
||||
t.Error("CreatedAt should be set")
|
||||
}
|
||||
|
||||
if !issue.UpdatedAt.After(time.Time{}) {
|
||||
t.Error("UpdatedAt should be set")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateIssueValidation(t *testing.T) {
|
||||
store := setupTestMemory(t)
|
||||
defer store.Close()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
issue *types.Issue
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "valid issue",
|
||||
issue: &types.Issue{
|
||||
Title: "Valid",
|
||||
Status: types.StatusOpen,
|
||||
Priority: 2,
|
||||
IssueType: types.TypeTask,
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "missing title",
|
||||
issue: &types.Issue{
|
||||
Status: types.StatusOpen,
|
||||
Priority: 2,
|
||||
IssueType: types.TypeTask,
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "invalid priority",
|
||||
issue: &types.Issue{
|
||||
Title: "Test",
|
||||
Status: types.StatusOpen,
|
||||
Priority: 10,
|
||||
IssueType: types.TypeTask,
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "invalid status",
|
||||
issue: &types.Issue{
|
||||
Title: "Test",
|
||||
Status: "invalid",
|
||||
Priority: 2,
|
||||
IssueType: types.TypeTask,
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := store.CreateIssue(ctx, tt.issue, "test-user")
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("CreateIssue() error = %v, wantErr %v", err, tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetIssue(t *testing.T) {
|
||||
store := setupTestMemory(t)
|
||||
defer store.Close()
|
||||
|
||||
ctx := context.Background()
|
||||
original := &types.Issue{
|
||||
Title: "Test issue",
|
||||
Description: "Description",
|
||||
Design: "Design notes",
|
||||
AcceptanceCriteria: "Acceptance",
|
||||
Notes: "Notes",
|
||||
Status: types.StatusOpen,
|
||||
Priority: 1,
|
||||
IssueType: types.TypeFeature,
|
||||
Assignee: "alice",
|
||||
}
|
||||
|
||||
err := store.CreateIssue(ctx, original, "test-user")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateIssue failed: %v", err)
|
||||
}
|
||||
|
||||
// Retrieve the issue
|
||||
retrieved, err := store.GetIssue(ctx, original.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetIssue failed: %v", err)
|
||||
}
|
||||
|
||||
if retrieved == nil {
|
||||
t.Fatal("GetIssue returned nil")
|
||||
}
|
||||
|
||||
if retrieved.ID != original.ID {
|
||||
t.Errorf("ID mismatch: got %v, want %v", retrieved.ID, original.ID)
|
||||
}
|
||||
|
||||
if retrieved.Title != original.Title {
|
||||
t.Errorf("Title mismatch: got %v, want %v", retrieved.Title, original.Title)
|
||||
}
|
||||
|
||||
if retrieved.Description != original.Description {
|
||||
t.Errorf("Description mismatch: got %v, want %v", retrieved.Description, original.Description)
|
||||
}
|
||||
|
||||
if retrieved.Assignee != original.Assignee {
|
||||
t.Errorf("Assignee mismatch: got %v, want %v", retrieved.Assignee, original.Assignee)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetIssueNotFound(t *testing.T) {
|
||||
store := setupTestMemory(t)
|
||||
defer store.Close()
|
||||
|
||||
ctx := context.Background()
|
||||
issue, err := store.GetIssue(ctx, "bd-999")
|
||||
if err != nil {
|
||||
t.Fatalf("GetIssue failed: %v", err)
|
||||
}
|
||||
|
||||
if issue != nil {
|
||||
t.Errorf("Expected nil for non-existent issue, got %v", issue)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateIssues(t *testing.T) {
|
||||
store := setupTestMemory(t)
|
||||
defer store.Close()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
issues []*types.Issue
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "empty batch",
|
||||
issues: []*types.Issue{},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "single issue",
|
||||
issues: []*types.Issue{
|
||||
{Title: "Single issue", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask},
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "multiple issues",
|
||||
issues: []*types.Issue{
|
||||
{Title: "Issue 1", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask},
|
||||
{Title: "Issue 2", Status: types.StatusInProgress, Priority: 2, IssueType: types.TypeBug},
|
||||
{Title: "Issue 3", Status: types.StatusOpen, Priority: 3, IssueType: types.TypeFeature},
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "validation error - missing title",
|
||||
issues: []*types.Issue{
|
||||
{Title: "Valid issue", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask},
|
||||
{Title: "", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask},
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "duplicate ID within batch error",
|
||||
issues: []*types.Issue{
|
||||
{ID: "dup-1", Title: "First", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask},
|
||||
{ID: "dup-1", Title: "Second", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask},
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Create fresh storage for each test
|
||||
testStore := setupTestMemory(t)
|
||||
defer testStore.Close()
|
||||
|
||||
err := testStore.CreateIssues(ctx, tt.issues, "test-user")
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("CreateIssues() error = %v, wantErr %v", err, tt.wantErr)
|
||||
}
|
||||
|
||||
if !tt.wantErr && len(tt.issues) > 0 {
|
||||
// Verify all issues got IDs
|
||||
for i, issue := range tt.issues {
|
||||
if issue.ID == "" {
|
||||
t.Errorf("issue %d: ID should be set", i)
|
||||
}
|
||||
if !issue.CreatedAt.After(time.Time{}) {
|
||||
t.Errorf("issue %d: CreatedAt should be set", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateIssue(t *testing.T) {
|
||||
store := setupTestMemory(t)
|
||||
defer store.Close()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Create an issue
|
||||
issue := &types.Issue{
|
||||
Title: "Original",
|
||||
Status: types.StatusOpen,
|
||||
Priority: 2,
|
||||
IssueType: types.TypeTask,
|
||||
}
|
||||
if err := store.CreateIssue(ctx, issue, "test-user"); err != nil {
|
||||
t.Fatalf("CreateIssue failed: %v", err)
|
||||
}
|
||||
|
||||
// Update it
|
||||
updates := map[string]interface{}{
|
||||
"title": "Updated",
|
||||
"priority": 1,
|
||||
"status": string(types.StatusInProgress),
|
||||
}
|
||||
if err := store.UpdateIssue(ctx, issue.ID, updates, "test-user"); err != nil {
|
||||
t.Fatalf("UpdateIssue failed: %v", err)
|
||||
}
|
||||
|
||||
// Retrieve and verify
|
||||
updated, err := store.GetIssue(ctx, issue.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetIssue failed: %v", err)
|
||||
}
|
||||
|
||||
if updated.Title != "Updated" {
|
||||
t.Errorf("Title not updated: got %v", updated.Title)
|
||||
}
|
||||
|
||||
if updated.Priority != 1 {
|
||||
t.Errorf("Priority not updated: got %v", updated.Priority)
|
||||
}
|
||||
|
||||
if updated.Status != types.StatusInProgress {
|
||||
t.Errorf("Status not updated: got %v", updated.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCloseIssue(t *testing.T) {
|
||||
store := setupTestMemory(t)
|
||||
defer store.Close()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Create an issue
|
||||
issue := &types.Issue{
|
||||
Title: "Test",
|
||||
Status: types.StatusOpen,
|
||||
Priority: 1,
|
||||
IssueType: types.TypeTask,
|
||||
}
|
||||
if err := store.CreateIssue(ctx, issue, "test-user"); err != nil {
|
||||
t.Fatalf("CreateIssue failed: %v", err)
|
||||
}
|
||||
|
||||
// Close it
|
||||
if err := store.CloseIssue(ctx, issue.ID, "Completed", "test-user"); err != nil {
|
||||
t.Fatalf("CloseIssue failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify
|
||||
closed, err := store.GetIssue(ctx, issue.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetIssue failed: %v", err)
|
||||
}
|
||||
|
||||
if closed.Status != types.StatusClosed {
|
||||
t.Errorf("Status should be closed, got %v", closed.Status)
|
||||
}
|
||||
|
||||
if closed.ClosedAt == nil {
|
||||
t.Error("ClosedAt should be set")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchIssues(t *testing.T) {
|
||||
store := setupTestMemory(t)
|
||||
defer store.Close()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Create test issues
|
||||
issues := []*types.Issue{
|
||||
{Title: "Bug fix", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeBug},
|
||||
{Title: "New feature", Status: types.StatusInProgress, Priority: 2, IssueType: types.TypeFeature},
|
||||
{Title: "Task", Status: types.StatusOpen, Priority: 3, IssueType: types.TypeTask},
|
||||
}
|
||||
|
||||
for _, issue := range issues {
|
||||
if err := store.CreateIssue(ctx, issue, "test-user"); err != nil {
|
||||
t.Fatalf("CreateIssue failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
query string
|
||||
filter types.IssueFilter
|
||||
wantSize int
|
||||
}{
|
||||
{
|
||||
name: "all issues",
|
||||
query: "",
|
||||
filter: types.IssueFilter{},
|
||||
wantSize: 3,
|
||||
},
|
||||
{
|
||||
name: "search by title",
|
||||
query: "feature",
|
||||
filter: types.IssueFilter{},
|
||||
wantSize: 1,
|
||||
},
|
||||
{
|
||||
name: "filter by status",
|
||||
query: "",
|
||||
filter: types.IssueFilter{Status: func() *types.Status { s := types.StatusOpen; return &s }()},
|
||||
wantSize: 2,
|
||||
},
|
||||
{
|
||||
name: "filter by priority",
|
||||
query: "",
|
||||
filter: types.IssueFilter{Priority: func() *int { p := 1; return &p }()},
|
||||
wantSize: 1,
|
||||
},
|
||||
{
|
||||
name: "filter by type",
|
||||
query: "",
|
||||
filter: types.IssueFilter{IssueType: func() *types.IssueType { t := types.TypeBug; return &t }()},
|
||||
wantSize: 1,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
results, err := store.SearchIssues(ctx, tt.query, tt.filter)
|
||||
if err != nil {
|
||||
t.Fatalf("SearchIssues failed: %v", err)
|
||||
}
|
||||
|
||||
if len(results) != tt.wantSize {
|
||||
t.Errorf("Expected %d results, got %d", tt.wantSize, len(results))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDependencies(t *testing.T) {
|
||||
store := setupTestMemory(t)
|
||||
defer store.Close()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Create two issues
|
||||
issue1 := &types.Issue{
|
||||
Title: "Issue 1",
|
||||
Status: types.StatusOpen,
|
||||
Priority: 1,
|
||||
IssueType: types.TypeTask,
|
||||
}
|
||||
issue2 := &types.Issue{
|
||||
Title: "Issue 2",
|
||||
Status: types.StatusOpen,
|
||||
Priority: 1,
|
||||
IssueType: types.TypeTask,
|
||||
}
|
||||
|
||||
if err := store.CreateIssue(ctx, issue1, "test-user"); err != nil {
|
||||
t.Fatalf("CreateIssue failed: %v", err)
|
||||
}
|
||||
if err := store.CreateIssue(ctx, issue2, "test-user"); err != nil {
|
||||
t.Fatalf("CreateIssue failed: %v", err)
|
||||
}
|
||||
|
||||
// Add dependency
|
||||
dep := &types.Dependency{
|
||||
IssueID: issue1.ID,
|
||||
DependsOnID: issue2.ID,
|
||||
Type: types.DepBlocks,
|
||||
}
|
||||
if err := store.AddDependency(ctx, dep, "test-user"); err != nil {
|
||||
t.Fatalf("AddDependency failed: %v", err)
|
||||
}
|
||||
|
||||
// Get dependencies
|
||||
deps, err := store.GetDependencies(ctx, issue1.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetDependencies failed: %v", err)
|
||||
}
|
||||
|
||||
if len(deps) != 1 {
|
||||
t.Errorf("Expected 1 dependency, got %d", len(deps))
|
||||
}
|
||||
|
||||
if deps[0].ID != issue2.ID {
|
||||
t.Errorf("Dependency mismatch: got %v", deps[0].ID)
|
||||
}
|
||||
|
||||
// Get dependents
|
||||
dependents, err := store.GetDependents(ctx, issue2.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetDependents failed: %v", err)
|
||||
}
|
||||
|
||||
if len(dependents) != 1 {
|
||||
t.Errorf("Expected 1 dependent, got %d", len(dependents))
|
||||
}
|
||||
|
||||
// Remove dependency
|
||||
if err := store.RemoveDependency(ctx, issue1.ID, issue2.ID, "test-user"); err != nil {
|
||||
t.Fatalf("RemoveDependency failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify removed
|
||||
deps, err = store.GetDependencies(ctx, issue1.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetDependencies failed: %v", err)
|
||||
}
|
||||
|
||||
if len(deps) != 0 {
|
||||
t.Errorf("Expected 0 dependencies after removal, got %d", len(deps))
|
||||
}
|
||||
}
|
||||
|
||||
func TestLabels(t *testing.T) {
|
||||
store := setupTestMemory(t)
|
||||
defer store.Close()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Create an issue
|
||||
issue := &types.Issue{
|
||||
Title: "Test",
|
||||
Status: types.StatusOpen,
|
||||
Priority: 1,
|
||||
IssueType: types.TypeTask,
|
||||
}
|
||||
if err := store.CreateIssue(ctx, issue, "test-user"); err != nil {
|
||||
t.Fatalf("CreateIssue failed: %v", err)
|
||||
}
|
||||
|
||||
// Add labels
|
||||
if err := store.AddLabel(ctx, issue.ID, "bug", "test-user"); err != nil {
|
||||
t.Fatalf("AddLabel failed: %v", err)
|
||||
}
|
||||
if err := store.AddLabel(ctx, issue.ID, "critical", "test-user"); err != nil {
|
||||
t.Fatalf("AddLabel failed: %v", err)
|
||||
}
|
||||
|
||||
// Get labels
|
||||
labels, err := store.GetLabels(ctx, issue.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetLabels failed: %v", err)
|
||||
}
|
||||
|
||||
if len(labels) != 2 {
|
||||
t.Errorf("Expected 2 labels, got %d", len(labels))
|
||||
}
|
||||
|
||||
// Remove label
|
||||
if err := store.RemoveLabel(ctx, issue.ID, "bug", "test-user"); err != nil {
|
||||
t.Fatalf("RemoveLabel failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify
|
||||
labels, err = store.GetLabels(ctx, issue.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetLabels failed: %v", err)
|
||||
}
|
||||
|
||||
if len(labels) != 1 {
|
||||
t.Errorf("Expected 1 label after removal, got %d", len(labels))
|
||||
}
|
||||
}
|
||||
|
||||
func TestComments(t *testing.T) {
|
||||
store := setupTestMemory(t)
|
||||
defer store.Close()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Create an issue
|
||||
issue := &types.Issue{
|
||||
Title: "Test",
|
||||
Status: types.StatusOpen,
|
||||
Priority: 1,
|
||||
IssueType: types.TypeTask,
|
||||
}
|
||||
if err := store.CreateIssue(ctx, issue, "test-user"); err != nil {
|
||||
t.Fatalf("CreateIssue failed: %v", err)
|
||||
}
|
||||
|
||||
// Add comment
|
||||
comment, err := store.AddIssueComment(ctx, issue.ID, "alice", "First comment")
|
||||
if err != nil {
|
||||
t.Fatalf("AddIssueComment failed: %v", err)
|
||||
}
|
||||
|
||||
if comment == nil {
|
||||
t.Fatal("Comment should not be nil")
|
||||
}
|
||||
|
||||
// Get comments
|
||||
comments, err := store.GetIssueComments(ctx, issue.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetIssueComments failed: %v", err)
|
||||
}
|
||||
|
||||
if len(comments) != 1 {
|
||||
t.Errorf("Expected 1 comment, got %d", len(comments))
|
||||
}
|
||||
|
||||
if comments[0].Text != "First comment" {
|
||||
t.Errorf("Comment text mismatch: got %v", comments[0].Text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadFromIssues(t *testing.T) {
|
||||
store := New("")
|
||||
defer store.Close()
|
||||
|
||||
issues := []*types.Issue{
|
||||
{
|
||||
ID: "bd-1",
|
||||
Title: "Issue 1",
|
||||
Status: types.StatusOpen,
|
||||
Priority: 1,
|
||||
IssueType: types.TypeTask,
|
||||
Labels: []string{"bug", "critical"},
|
||||
Dependencies: []*types.Dependency{{IssueID: "bd-1", DependsOnID: "bd-2", Type: types.DepBlocks}},
|
||||
},
|
||||
{
|
||||
ID: "bd-2",
|
||||
Title: "Issue 2",
|
||||
Status: types.StatusOpen,
|
||||
Priority: 1,
|
||||
IssueType: types.TypeTask,
|
||||
},
|
||||
}
|
||||
|
||||
if err := store.LoadFromIssues(issues); err != nil {
|
||||
t.Fatalf("LoadFromIssues failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify issues loaded
|
||||
ctx := context.Background()
|
||||
loaded, err := store.GetIssue(ctx, "bd-1")
|
||||
if err != nil {
|
||||
t.Fatalf("GetIssue failed: %v", err)
|
||||
}
|
||||
|
||||
if loaded == nil {
|
||||
t.Fatal("Issue should be loaded")
|
||||
}
|
||||
|
||||
if loaded.Title != "Issue 1" {
|
||||
t.Errorf("Title mismatch: got %v", loaded.Title)
|
||||
}
|
||||
|
||||
// Verify labels loaded
|
||||
if len(loaded.Labels) != 2 {
|
||||
t.Errorf("Expected 2 labels, got %d", len(loaded.Labels))
|
||||
}
|
||||
|
||||
// Verify dependencies loaded
|
||||
if len(loaded.Dependencies) != 1 {
|
||||
t.Errorf("Expected 1 dependency, got %d", len(loaded.Dependencies))
|
||||
}
|
||||
|
||||
// Verify counter updated
|
||||
if store.counters["bd"] != 2 {
|
||||
t.Errorf("Expected counter bd=2, got %d", store.counters["bd"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetAllIssues(t *testing.T) {
|
||||
store := setupTestMemory(t)
|
||||
defer store.Close()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Create issues
|
||||
for i := 1; i <= 3; i++ {
|
||||
issue := &types.Issue{
|
||||
Title: "Issue",
|
||||
Status: types.StatusOpen,
|
||||
Priority: 1,
|
||||
IssueType: types.TypeTask,
|
||||
}
|
||||
if err := store.CreateIssue(ctx, issue, "test-user"); err != nil {
|
||||
t.Fatalf("CreateIssue failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Get all
|
||||
all := store.GetAllIssues()
|
||||
if len(all) != 3 {
|
||||
t.Errorf("Expected 3 issues, got %d", len(all))
|
||||
}
|
||||
|
||||
// Verify sorted by ID
|
||||
for i := 1; i < len(all); i++ {
|
||||
if all[i-1].ID >= all[i].ID {
|
||||
t.Error("Issues should be sorted by ID")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDirtyTracking(t *testing.T) {
|
||||
store := setupTestMemory(t)
|
||||
defer store.Close()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Create an issue
|
||||
issue := &types.Issue{
|
||||
Title: "Test",
|
||||
Status: types.StatusOpen,
|
||||
Priority: 1,
|
||||
IssueType: types.TypeTask,
|
||||
}
|
||||
if err := store.CreateIssue(ctx, issue, "test-user"); err != nil {
|
||||
t.Fatalf("CreateIssue failed: %v", err)
|
||||
}
|
||||
|
||||
// Should be dirty
|
||||
dirty, err := store.GetDirtyIssues(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("GetDirtyIssues failed: %v", err)
|
||||
}
|
||||
|
||||
if len(dirty) != 1 {
|
||||
t.Errorf("Expected 1 dirty issue, got %d", len(dirty))
|
||||
}
|
||||
|
||||
// Clear dirty
|
||||
if err := store.ClearDirtyIssues(ctx); err != nil {
|
||||
t.Fatalf("ClearDirtyIssues failed: %v", err)
|
||||
}
|
||||
|
||||
dirty, err = store.GetDirtyIssues(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("GetDirtyIssues failed: %v", err)
|
||||
}
|
||||
|
||||
if len(dirty) != 0 {
|
||||
t.Errorf("Expected 0 dirty issues after clear, got %d", len(dirty))
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatistics(t *testing.T) {
|
||||
store := setupTestMemory(t)
|
||||
defer store.Close()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Create issues with different statuses
|
||||
issues := []*types.Issue{
|
||||
{Title: "Open 1", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask},
|
||||
{Title: "Open 2", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask},
|
||||
{Title: "In Progress", Status: types.StatusInProgress, Priority: 1, IssueType: types.TypeTask},
|
||||
{Title: "Closed", Status: types.StatusClosed, Priority: 1, IssueType: types.TypeTask, ClosedAt: func() *time.Time { t := time.Now(); return &t }()},
|
||||
}
|
||||
|
||||
for _, issue := range issues {
|
||||
if err := store.CreateIssue(ctx, issue, "test-user"); err != nil {
|
||||
t.Fatalf("CreateIssue failed: %v", err)
|
||||
}
|
||||
// Close the one marked as closed
|
||||
if issue.Status == types.StatusClosed {
|
||||
if err := store.CloseIssue(ctx, issue.ID, "Done", "test-user"); err != nil {
|
||||
t.Fatalf("CloseIssue failed: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stats, err := store.GetStatistics(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("GetStatistics failed: %v", err)
|
||||
}
|
||||
|
||||
if stats.TotalIssues != 4 {
|
||||
t.Errorf("Expected 4 total issues, got %d", stats.TotalIssues)
|
||||
}
|
||||
if stats.OpenIssues != 2 {
|
||||
t.Errorf("Expected 2 open issues, got %d", stats.OpenIssues)
|
||||
}
|
||||
if stats.InProgressIssues != 1 {
|
||||
t.Errorf("Expected 1 in-progress issue, got %d", stats.InProgressIssues)
|
||||
}
|
||||
if stats.ClosedIssues != 1 {
|
||||
t.Errorf("Expected 1 closed issue, got %d", stats.ClosedIssues)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigOperations(t *testing.T) {
|
||||
store := setupTestMemory(t)
|
||||
defer store.Close()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Set config
|
||||
if err := store.SetConfig(ctx, "test_key", "test_value"); err != nil {
|
||||
t.Fatalf("SetConfig failed: %v", err)
|
||||
}
|
||||
|
||||
// Get config
|
||||
value, err := store.GetConfig(ctx, "test_key")
|
||||
if err != nil {
|
||||
t.Fatalf("GetConfig failed: %v", err)
|
||||
}
|
||||
|
||||
if value != "test_value" {
|
||||
t.Errorf("Expected test_value, got %v", value)
|
||||
}
|
||||
|
||||
// Get all config
|
||||
allConfig, err := store.GetAllConfig(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("GetAllConfig failed: %v", err)
|
||||
}
|
||||
|
||||
if len(allConfig) < 1 {
|
||||
t.Error("Expected at least 1 config entry")
|
||||
}
|
||||
|
||||
// Delete config
|
||||
if err := store.DeleteConfig(ctx, "test_key"); err != nil {
|
||||
t.Fatalf("DeleteConfig failed: %v", err)
|
||||
}
|
||||
|
||||
value, err = store.GetConfig(ctx, "test_key")
|
||||
if err != nil {
|
||||
t.Fatalf("GetConfig failed: %v", err)
|
||||
}
|
||||
|
||||
if value != "" {
|
||||
t.Errorf("Expected empty value after delete, got %v", value)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMetadataOperations(t *testing.T) {
|
||||
store := setupTestMemory(t)
|
||||
defer store.Close()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Set metadata
|
||||
if err := store.SetMetadata(ctx, "hash", "abc123"); err != nil {
|
||||
t.Fatalf("SetMetadata failed: %v", err)
|
||||
}
|
||||
|
||||
// Get metadata
|
||||
value, err := store.GetMetadata(ctx, "hash")
|
||||
if err != nil {
|
||||
t.Fatalf("GetMetadata failed: %v", err)
|
||||
}
|
||||
|
||||
if value != "abc123" {
|
||||
t.Errorf("Expected abc123, got %v", value)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncAllCounters(t *testing.T) {
|
||||
store := New("")
|
||||
defer store.Close()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Load issues with different prefixes
|
||||
issues := []*types.Issue{
|
||||
{ID: "bd-5", Title: "Test 1", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask},
|
||||
{ID: "bd-10", Title: "Test 2", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask},
|
||||
{ID: "custom-3", Title: "Test 3", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask},
|
||||
}
|
||||
|
||||
if err := store.LoadFromIssues(issues); err != nil {
|
||||
t.Fatalf("LoadFromIssues failed: %v", err)
|
||||
}
|
||||
|
||||
// Manually corrupt counter
|
||||
store.counters["bd"] = 1
|
||||
|
||||
// Sync counters
|
||||
if err := store.SyncAllCounters(ctx); err != nil {
|
||||
t.Fatalf("SyncAllCounters failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify corrected
|
||||
if store.counters["bd"] != 10 {
|
||||
t.Errorf("Expected bd counter to be 10, got %d", store.counters["bd"])
|
||||
}
|
||||
|
||||
if store.counters["custom"] != 3 {
|
||||
t.Errorf("Expected custom counter to be 3, got %d", store.counters["custom"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestThreadSafety(t *testing.T) {
|
||||
store := setupTestMemory(t)
|
||||
defer store.Close()
|
||||
|
||||
ctx := context.Background()
|
||||
const numGoroutines = 10
|
||||
|
||||
// Run concurrent creates
|
||||
done := make(chan bool)
|
||||
for i := 0; i < numGoroutines; i++ {
|
||||
go func(n int) {
|
||||
issue := &types.Issue{
|
||||
Title: "Concurrent",
|
||||
Status: types.StatusOpen,
|
||||
Priority: 1,
|
||||
IssueType: types.TypeTask,
|
||||
}
|
||||
store.CreateIssue(ctx, issue, "test-user")
|
||||
done <- true
|
||||
}(i)
|
||||
}
|
||||
|
||||
// Wait for all goroutines
|
||||
for i := 0; i < numGoroutines; i++ {
|
||||
<-done
|
||||
}
|
||||
|
||||
// Verify all created
|
||||
stats, err := store.GetStatistics(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("GetStatistics failed: %v", err)
|
||||
}
|
||||
|
||||
if stats.TotalIssues != numGoroutines {
|
||||
t.Errorf("Expected %d issues, got %d", numGoroutines, stats.TotalIssues)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClose(t *testing.T) {
|
||||
store := setupTestMemory(t)
|
||||
|
||||
if store.closed {
|
||||
t.Error("Store should not be closed initially")
|
||||
}
|
||||
|
||||
if err := store.Close(); err != nil {
|
||||
t.Fatalf("Close failed: %v", err)
|
||||
}
|
||||
|
||||
if !store.closed {
|
||||
t.Error("Store should be closed")
|
||||
}
|
||||
}
|
||||
@@ -501,7 +501,10 @@ func (s *SQLiteStorage) GetDependencyTree(ctx context.Context, issueID string, m
|
||||
JOIN dependencies d ON i.id = d.issue_id
|
||||
JOIN tree t ON d.depends_on_id = t.id
|
||||
WHERE t.depth < ?
|
||||
AND t.path NOT LIKE '%' || i.id || '%'
|
||||
AND t.path != i.id
|
||||
AND t.path NOT LIKE i.id || '→%'
|
||||
AND t.path NOT LIKE '%→' || i.id || '→%'
|
||||
AND t.path NOT LIKE '%→' || i.id
|
||||
)
|
||||
SELECT id, title, status, priority, description, design,
|
||||
acceptance_criteria, notes, issue_type, assignee,
|
||||
@@ -539,7 +542,10 @@ func (s *SQLiteStorage) GetDependencyTree(ctx context.Context, issueID string, m
|
||||
JOIN dependencies d ON i.id = d.depends_on_id
|
||||
JOIN tree t ON d.issue_id = t.id
|
||||
WHERE t.depth < ?
|
||||
AND t.path NOT LIKE '%' || i.id || '%'
|
||||
AND t.path != i.id
|
||||
AND t.path NOT LIKE i.id || '→%'
|
||||
AND t.path NOT LIKE '%→' || i.id || '→%'
|
||||
AND t.path NOT LIKE '%→' || i.id
|
||||
)
|
||||
SELECT id, title, status, priority, description, design,
|
||||
acceptance_criteria, notes, issue_type, assignee,
|
||||
|
||||
@@ -800,3 +800,106 @@ func TestGetDependencyTree_Reverse(t *testing.T) {
|
||||
t.Errorf("Expected depth 2 for %s in reverse tree, got %d", issue3.ID, depthMap[issue3.ID])
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetDependencyTree_SubstringBug(t *testing.T) {
|
||||
store, cleanup := setupTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Create 10 issues so we have both bd-1 and bd-10 (substring issue)
|
||||
// The bug: when traversing from bd-10, bd-1 gets incorrectly excluded
|
||||
// because "bd-10" contains "bd-1" as a substring
|
||||
issues := make([]*types.Issue, 10)
|
||||
for i := 0; i < 10; i++ {
|
||||
issues[i] = &types.Issue{
|
||||
Title: fmt.Sprintf("Issue %d", i+1),
|
||||
Status: types.StatusOpen,
|
||||
Priority: 1,
|
||||
IssueType: types.TypeTask,
|
||||
}
|
||||
err := store.CreateIssue(ctx, issues[i], "test-user")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateIssue failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Create chain: bd-10 → bd-9 → bd-8 → bd-2 → bd-1
|
||||
// This tests the substring bug where bd-1 should appear but won't due to substring matching
|
||||
err := store.AddDependency(ctx, &types.Dependency{
|
||||
IssueID: issues[9].ID, // bd-10
|
||||
DependsOnID: issues[8].ID, // bd-9
|
||||
Type: types.DepBlocks,
|
||||
}, "test-user")
|
||||
if err != nil {
|
||||
t.Fatalf("AddDependency bd-10→bd-9 failed: %v", err)
|
||||
}
|
||||
|
||||
err = store.AddDependency(ctx, &types.Dependency{
|
||||
IssueID: issues[8].ID, // bd-9
|
||||
DependsOnID: issues[7].ID, // bd-8
|
||||
Type: types.DepBlocks,
|
||||
}, "test-user")
|
||||
if err != nil {
|
||||
t.Fatalf("AddDependency bd-9→bd-8 failed: %v", err)
|
||||
}
|
||||
|
||||
err = store.AddDependency(ctx, &types.Dependency{
|
||||
IssueID: issues[7].ID, // bd-8
|
||||
DependsOnID: issues[1].ID, // bd-2
|
||||
Type: types.DepBlocks,
|
||||
}, "test-user")
|
||||
if err != nil {
|
||||
t.Fatalf("AddDependency bd-8→bd-2 failed: %v", err)
|
||||
}
|
||||
|
||||
err = store.AddDependency(ctx, &types.Dependency{
|
||||
IssueID: issues[1].ID, // bd-2
|
||||
DependsOnID: issues[0].ID, // bd-1
|
||||
Type: types.DepBlocks,
|
||||
}, "test-user")
|
||||
if err != nil {
|
||||
t.Fatalf("AddDependency bd-2→bd-1 failed: %v", err)
|
||||
}
|
||||
|
||||
// Get tree starting from bd-10
|
||||
tree, err := store.GetDependencyTree(ctx, issues[9].ID, 10, false, false)
|
||||
if err != nil {
|
||||
t.Fatalf("GetDependencyTree failed: %v", err)
|
||||
}
|
||||
|
||||
// Create map of issue IDs in tree for easy checking
|
||||
treeIDs := make(map[string]bool)
|
||||
for _, node := range tree {
|
||||
treeIDs[node.ID] = true
|
||||
}
|
||||
|
||||
// Verify all issues in the chain appear in the tree
|
||||
// This is the KEY test: bd-1 should be in the tree
|
||||
// With the substring bug, bd-1 will be missing because "bd-10" contains "bd-1"
|
||||
expectedIssues := []int{9, 8, 7, 1, 0} // bd-10, bd-9, bd-8, bd-2, bd-1
|
||||
for _, idx := range expectedIssues {
|
||||
if !treeIDs[issues[idx].ID] {
|
||||
t.Errorf("Expected %s in dependency tree, but it was missing (substring bug)", issues[idx].ID)
|
||||
}
|
||||
}
|
||||
|
||||
// Verify we have the correct number of nodes
|
||||
if len(tree) != 5 {
|
||||
t.Errorf("Expected 5 nodes in tree, got %d. Missing nodes indicate substring bug.", len(tree))
|
||||
}
|
||||
|
||||
// Verify depths are correct
|
||||
depthMap := make(map[string]int)
|
||||
for _, node := range tree {
|
||||
depthMap[node.ID] = node.Depth
|
||||
}
|
||||
|
||||
// Check depths: bd-10(0) → bd-9(1) → bd-8(2) → bd-2(3) → bd-1(4)
|
||||
if depthMap[issues[9].ID] != 0 {
|
||||
t.Errorf("Expected bd-10 at depth 0, got %d", depthMap[issues[9].ID])
|
||||
}
|
||||
if depthMap[issues[0].ID] != 4 {
|
||||
t.Errorf("Expected bd-1 at depth 4, got %d", depthMap[issues[0].ID])
|
||||
}
|
||||
}
|
||||
|
||||
165
internal/storage/sqlite/prefix_validation_test.go
Normal file
165
internal/storage/sqlite/prefix_validation_test.go
Normal file
@@ -0,0 +1,165 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/steveyegge/beads/internal/types"
|
||||
)
|
||||
|
||||
func TestPrefixValidation(t *testing.T) {
|
||||
tmpDir, err := os.MkdirTemp("", "beads-prefix-test-*")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
dbPath := filepath.Join(tmpDir, "test.db")
|
||||
store, err := New(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create storage: %v", err)
|
||||
}
|
||||
defer store.Close()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Set prefix to "test"
|
||||
if err := store.SetConfig(ctx, "issue_prefix", "test"); err != nil {
|
||||
t.Fatalf("failed to set prefix: %v", err)
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
issueID string
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "valid prefix - matches",
|
||||
issueID: "test-123",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "invalid prefix - wrong prefix",
|
||||
issueID: "bd-456",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "invalid prefix - no dash",
|
||||
issueID: "test123",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "invalid prefix - empty",
|
||||
issueID: "",
|
||||
wantErr: false, // Empty ID triggers auto-generation
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
issue := &types.Issue{
|
||||
ID: tt.issueID,
|
||||
Title: "Test issue",
|
||||
Status: types.StatusOpen,
|
||||
Priority: 1,
|
||||
IssueType: types.TypeTask,
|
||||
}
|
||||
|
||||
err := store.CreateIssue(ctx, issue, "test-user")
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("CreateIssue() error = %v, wantErr %v", err, tt.wantErr)
|
||||
}
|
||||
|
||||
// If we expected success and the ID was empty, verify it was generated with correct prefix
|
||||
if err == nil && tt.issueID == "" {
|
||||
if issue.ID == "" {
|
||||
t.Error("ID should be generated")
|
||||
}
|
||||
if issue.ID[:5] != "test-" {
|
||||
t.Errorf("Generated ID should have prefix 'test-', got %s", issue.ID)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrefixValidationBatch(t *testing.T) {
|
||||
tmpDir, err := os.MkdirTemp("", "beads-prefix-batch-test-*")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
dbPath := filepath.Join(tmpDir, "test.db")
|
||||
store, err := New(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create storage: %v", err)
|
||||
}
|
||||
defer store.Close()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Set prefix to "batch"
|
||||
if err := store.SetConfig(ctx, "issue_prefix", "batch"); err != nil {
|
||||
t.Fatalf("failed to set prefix: %v", err)
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
issues []*types.Issue
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "all valid prefixes",
|
||||
issues: []*types.Issue{
|
||||
{ID: "batch-1", Title: "Test 1", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask},
|
||||
{ID: "batch-2", Title: "Test 2", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask},
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "one invalid prefix in batch",
|
||||
issues: []*types.Issue{
|
||||
{ID: "batch-10", Title: "Test 1", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask},
|
||||
{ID: "wrong-20", Title: "Test 2", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask},
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "mixed auto-generated and explicit",
|
||||
issues: []*types.Issue{
|
||||
{ID: "batch-100", Title: "Explicit ID", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask},
|
||||
{ID: "", Title: "Auto ID", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask},
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "mixed with invalid prefix",
|
||||
issues: []*types.Issue{
|
||||
{ID: "", Title: "Auto ID", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask},
|
||||
{ID: "invalid-500", Title: "Wrong prefix", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask},
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := store.CreateIssues(ctx, tt.issues, "test-user")
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("CreateIssues() error = %v, wantErr %v", err, tt.wantErr)
|
||||
}
|
||||
|
||||
// For successful batches, verify all IDs have correct prefix
|
||||
if err == nil {
|
||||
for i, issue := range tt.issues {
|
||||
if issue.ID[:6] != "batch-" {
|
||||
t.Errorf("Issue %d: ID should have prefix 'batch-', got %s", i, issue.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -617,19 +617,19 @@ func (s *SQLiteStorage) CreateIssue(ctx context.Context, issue *types.Issue, act
|
||||
}
|
||||
}()
|
||||
|
||||
// Get prefix from config (needed for both ID generation and validation)
|
||||
var prefix string
|
||||
err = conn.QueryRowContext(ctx, `SELECT value FROM config WHERE key = ?`, "issue_prefix").Scan(&prefix)
|
||||
if err == sql.ErrNoRows || prefix == "" {
|
||||
// CRITICAL: Reject operation if issue_prefix config is missing (bd-166)
|
||||
// This prevents duplicate issues with wrong prefix
|
||||
return fmt.Errorf("database not initialized: issue_prefix config is missing (run 'bd init --prefix <prefix>' first)")
|
||||
} else if err != nil {
|
||||
return fmt.Errorf("failed to get config: %w", err)
|
||||
}
|
||||
|
||||
// Generate ID if not set (inside transaction to prevent race conditions)
|
||||
if issue.ID == "" {
|
||||
// Get prefix from config
|
||||
var prefix string
|
||||
err := conn.QueryRowContext(ctx, `SELECT value FROM config WHERE key = ?`, "issue_prefix").Scan(&prefix)
|
||||
if err == sql.ErrNoRows || prefix == "" {
|
||||
// CRITICAL: Reject operation if issue_prefix config is missing (bd-166)
|
||||
// This prevents duplicate issues with wrong prefix
|
||||
return fmt.Errorf("database not initialized: issue_prefix config is missing (run 'bd init --prefix <prefix>' first)")
|
||||
} else if err != nil {
|
||||
return fmt.Errorf("failed to get config: %w", err)
|
||||
}
|
||||
|
||||
// Atomically initialize counter (if needed) and get next ID (within transaction)
|
||||
// This ensures the counter starts from the max existing ID, not 1
|
||||
// CRITICAL: We rely on BEGIN IMMEDIATE above to serialize this operation across processes
|
||||
@@ -665,6 +665,13 @@ func (s *SQLiteStorage) CreateIssue(ctx context.Context, issue *types.Issue, act
|
||||
}
|
||||
|
||||
issue.ID = fmt.Sprintf("%s-%d", prefix, nextID)
|
||||
} else {
|
||||
// Validate that explicitly provided ID matches the configured prefix (bd-177)
|
||||
// This prevents wrong-prefix bugs when IDs are manually specified
|
||||
expectedPrefix := prefix + "-"
|
||||
if !strings.HasPrefix(issue.ID, expectedPrefix) {
|
||||
return fmt.Errorf("issue ID '%s' does not match configured prefix '%s'", issue.ID, prefix)
|
||||
}
|
||||
}
|
||||
|
||||
// Insert issue
|
||||
@@ -743,19 +750,7 @@ func validateBatchIssues(issues []*types.Issue) error {
|
||||
|
||||
// generateBatchIDs generates IDs for all issues that need them atomically
|
||||
func generateBatchIDs(ctx context.Context, conn *sql.Conn, issues []*types.Issue, dbPath string) error {
|
||||
// Count how many issues need IDs
|
||||
needIDCount := 0
|
||||
for _, issue := range issues {
|
||||
if issue.ID == "" {
|
||||
needIDCount++
|
||||
}
|
||||
}
|
||||
|
||||
if needIDCount == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Get prefix from config
|
||||
// Get prefix from config (needed for both generation and validation)
|
||||
var prefix string
|
||||
err := conn.QueryRowContext(ctx, `SELECT value FROM config WHERE key = ?`, "issue_prefix").Scan(&prefix)
|
||||
if err == sql.ErrNoRows || prefix == "" {
|
||||
@@ -765,6 +760,24 @@ func generateBatchIDs(ctx context.Context, conn *sql.Conn, issues []*types.Issue
|
||||
return fmt.Errorf("failed to get config: %w", err)
|
||||
}
|
||||
|
||||
// Count how many issues need IDs and validate explicitly provided IDs
|
||||
needIDCount := 0
|
||||
expectedPrefix := prefix + "-"
|
||||
for _, issue := range issues {
|
||||
if issue.ID == "" {
|
||||
needIDCount++
|
||||
} else {
|
||||
// Validate that explicitly provided ID matches the configured prefix (bd-177)
|
||||
if !strings.HasPrefix(issue.ID, expectedPrefix) {
|
||||
return fmt.Errorf("issue ID '%s' does not match configured prefix '%s'", issue.ID, prefix)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if needIDCount == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Atomically reserve ID range
|
||||
var nextID int
|
||||
err = conn.QueryRowContext(ctx, `
|
||||
|
||||
Reference in New Issue
Block a user