1534 lines
38 KiB
Go
1534 lines
38 KiB
Go
// 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/storage"
|
|
"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
|
|
|
|
// Indexes for O(1) lookups
|
|
externalRefToID map[string]string // ExternalRef -> IssueID
|
|
|
|
// 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),
|
|
externalRefToID: make(map[string]string),
|
|
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
|
|
|
|
// Index external ref for O(1) lookup
|
|
if issue.ExternalRef != nil && *issue.ExternalRef != "" {
|
|
m.externalRefToID[*issue.ExternalRef] = issue.ID
|
|
}
|
|
|
|
// 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
|
|
}
|
|
}
|
|
|
|
// Update hierarchical child counters based on issue ID
|
|
// e.g. "bd-a3f8e9.2" -> parent "bd-a3f8e9" counter 2
|
|
if parentID, childNum, ok := extractParentAndChildNumber(issue.ID); ok {
|
|
if m.counters[parentID] < childNum {
|
|
m.counters[parentID] = childNum
|
|
}
|
|
}
|
|
}
|
|
|
|
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) {
|
|
lastDash := strings.LastIndex(id, "-")
|
|
if lastDash == -1 {
|
|
return "", 0
|
|
}
|
|
|
|
prefix := id[:lastDash]
|
|
suffix := id[lastDash+1:]
|
|
|
|
var num int
|
|
_, err := fmt.Sscanf(suffix, "%d", &num)
|
|
if err != nil {
|
|
return "", 0
|
|
}
|
|
return prefix, num
|
|
}
|
|
|
|
// extractParentAndChildNumber extracts the parent ID and numeric child counter from an issue ID like
|
|
// "bd-a3f8e9.2" -> ("bd-a3f8e9", 2, true).
|
|
func extractParentAndChildNumber(id string) (string, int, bool) {
|
|
lastDot := strings.LastIndex(id, ".")
|
|
if lastDot == -1 {
|
|
return "", 0, false
|
|
}
|
|
|
|
parentID := id[:lastDot]
|
|
suffix := id[lastDot+1:]
|
|
|
|
var num int
|
|
if _, err := fmt.Sscanf(suffix, "%d", &num); err != nil {
|
|
return "", 0, false
|
|
}
|
|
|
|
return parentID, num, true
|
|
}
|
|
|
|
// 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
|
|
|
|
// Index external ref for O(1) lookup
|
|
if issue.ExternalRef != nil && *issue.ExternalRef != "" {
|
|
m.externalRefToID[*issue.ExternalRef] = issue.ID
|
|
}
|
|
|
|
// 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
|
|
|
|
// Index external ref for O(1) lookup
|
|
if issue.ExternalRef != nil && *issue.ExternalRef != "" {
|
|
m.externalRefToID[*issue.ExternalRef] = issue.ID
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// GetIssueByExternalRef retrieves an issue by external reference
|
|
func (m *MemoryStorage) GetIssueByExternalRef(ctx context.Context, externalRef string) (*types.Issue, error) {
|
|
m.mu.RLock()
|
|
defer m.mu.RUnlock()
|
|
|
|
// O(1) lookup using index
|
|
issueID, exists := m.externalRefToID[externalRef]
|
|
if !exists {
|
|
return nil, nil
|
|
}
|
|
|
|
issue, exists := m.issues[issueID]
|
|
if !exists {
|
|
return nil, nil
|
|
}
|
|
|
|
// Return a 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
|
|
}
|
|
|
|
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":
|
|
// Update external ref index
|
|
oldRef := issue.ExternalRef
|
|
if v, ok := value.(string); ok {
|
|
// Remove old index entry if exists
|
|
if oldRef != nil && *oldRef != "" {
|
|
delete(m.externalRefToID, *oldRef)
|
|
}
|
|
// Add new index entry
|
|
if v != "" {
|
|
m.externalRefToID[v] = id
|
|
}
|
|
issue.ExternalRef = &v
|
|
} else if value == nil {
|
|
// Remove old index entry if exists
|
|
if oldRef != nil && *oldRef != "" {
|
|
delete(m.externalRefToID, *oldRef)
|
|
}
|
|
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)
|
|
}
|
|
|
|
// DeleteIssue permanently deletes an issue and all associated data
|
|
func (m *MemoryStorage) DeleteIssue(ctx context.Context, id string) error {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
|
|
// Check if issue exists
|
|
issue, ok := m.issues[id]
|
|
if !ok {
|
|
return fmt.Errorf("issue not found: %s", id)
|
|
}
|
|
|
|
// Remove external ref index entry
|
|
if issue.ExternalRef != nil && *issue.ExternalRef != "" {
|
|
delete(m.externalRefToID, *issue.ExternalRef)
|
|
}
|
|
|
|
// Delete the issue
|
|
delete(m.issues, id)
|
|
|
|
// Delete associated data
|
|
delete(m.dependencies, id)
|
|
delete(m.labels, id)
|
|
delete(m.events, id)
|
|
delete(m.comments, id)
|
|
delete(m.dirty, id)
|
|
|
|
return nil
|
|
}
|
|
|
|
// 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
|
|
}
|
|
}
|
|
|
|
// Parent filtering (bd-yqhh): filter children by parent issue
|
|
if filter.ParentID != nil {
|
|
isChild := false
|
|
for _, dep := range m.dependencies[issue.ID] {
|
|
if dep.Type == types.DepParentChild && dep.DependsOnID == *filter.ParentID {
|
|
isChild = true
|
|
break
|
|
}
|
|
}
|
|
if !isChild {
|
|
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
|
|
}
|
|
|
|
// GetDependencyCounts returns dependency and dependent counts for multiple issues
|
|
func (m *MemoryStorage) GetDependencyCounts(ctx context.Context, issueIDs []string) (map[string]*types.DependencyCounts, error) {
|
|
m.mu.RLock()
|
|
defer m.mu.RUnlock()
|
|
|
|
result := make(map[string]*types.DependencyCounts)
|
|
|
|
// Initialize all requested IDs with zero counts
|
|
for _, id := range issueIDs {
|
|
result[id] = &types.DependencyCounts{
|
|
DependencyCount: 0,
|
|
DependentCount: 0,
|
|
}
|
|
}
|
|
|
|
// Build a set for quick lookup
|
|
idSet := make(map[string]bool)
|
|
for _, id := range issueIDs {
|
|
idSet[id] = true
|
|
}
|
|
|
|
// Count dependencies (issues that this issue depends on)
|
|
for _, id := range issueIDs {
|
|
if deps, exists := m.dependencies[id]; exists {
|
|
result[id].DependencyCount = len(deps)
|
|
}
|
|
}
|
|
|
|
// Count dependents (issues that depend on this issue)
|
|
for _, deps := range m.dependencies {
|
|
for _, dep := range deps {
|
|
if idSet[dep.DependsOnID] {
|
|
result[dep.DependsOnID].DependentCount++
|
|
}
|
|
}
|
|
}
|
|
|
|
return result, 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
|
|
}
|
|
|
|
// GetDirtyIssueHash returns the hash for dirty issue tracking
|
|
func (m *MemoryStorage) GetDirtyIssueHash(ctx context.Context, issueID string) (string, error) {
|
|
// Memory storage doesn't track dirty hashes, return empty string
|
|
return "", nil
|
|
}
|
|
|
|
// GetExportHash returns the hash for export tracking
|
|
func (m *MemoryStorage) GetExportHash(ctx context.Context, issueID string) (string, error) {
|
|
// Memory storage doesn't track export hashes, return empty string
|
|
return "", nil
|
|
}
|
|
|
|
// SetExportHash sets the hash for export tracking
|
|
func (m *MemoryStorage) SetExportHash(ctx context.Context, issueID, hash string) error {
|
|
// Memory storage doesn't track export hashes, no-op
|
|
return nil
|
|
}
|
|
|
|
// ClearAllExportHashes clears all export hashes
|
|
func (m *MemoryStorage) ClearAllExportHashes(ctx context.Context) error {
|
|
// Memory storage doesn't track export hashes, no-op
|
|
return nil
|
|
}
|
|
|
|
// GetJSONLFileHash gets the JSONL file hash
|
|
func (m *MemoryStorage) GetJSONLFileHash(ctx context.Context) (string, error) {
|
|
// Memory storage doesn't track JSONL file hashes, return empty string
|
|
return "", nil
|
|
}
|
|
|
|
// SetJSONLFileHash sets the JSONL file hash
|
|
func (m *MemoryStorage) SetJSONLFileHash(ctx context.Context, fileHash string) error {
|
|
// Memory storage doesn't track JSONL file hashes, no-op
|
|
return nil
|
|
}
|
|
|
|
// GetDependencyTree gets the dependency tree for an issue
|
|
func (m *MemoryStorage) GetDependencyTree(ctx context.Context, issueID string, maxDepth int, showAllPaths bool, reverse bool) ([]*types.TreeNode, error) {
|
|
// Simplified implementation - just return direct dependencies
|
|
// Note: reverse parameter is accepted for interface compatibility but not fully implemented in memory storage
|
|
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) GetLabelsForIssues(ctx context.Context, issueIDs []string) (map[string][]string, error) {
|
|
m.mu.RLock()
|
|
defer m.mu.RUnlock()
|
|
|
|
result := make(map[string][]string)
|
|
for _, issueID := range issueIDs {
|
|
if labels, exists := m.labels[issueID]; exists {
|
|
result[issueID] = labels
|
|
}
|
|
}
|
|
return result, 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
|
|
}
|
|
|
|
// GetReadyWork returns issues that are ready to work on (no open blockers)
|
|
func (m *MemoryStorage) GetReadyWork(ctx context.Context, filter types.WorkFilter) ([]*types.Issue, error) {
|
|
m.mu.RLock()
|
|
defer m.mu.RUnlock()
|
|
|
|
var results []*types.Issue
|
|
|
|
for _, issue := range m.issues {
|
|
// Skip pinned issues - they are context markers, not actionable work (bd-o9o)
|
|
if issue.Pinned {
|
|
continue
|
|
}
|
|
|
|
// Status filtering: default to open OR in_progress if not specified
|
|
if filter.Status == "" {
|
|
if issue.Status != types.StatusOpen && issue.Status != types.StatusInProgress {
|
|
continue
|
|
}
|
|
} else if issue.Status != filter.Status {
|
|
continue
|
|
}
|
|
|
|
// Priority filtering
|
|
if filter.Priority != nil && issue.Priority != *filter.Priority {
|
|
continue
|
|
}
|
|
|
|
// Type filtering (gt-7xtn)
|
|
if filter.Type != "" {
|
|
if string(issue.IssueType) != filter.Type {
|
|
continue
|
|
}
|
|
} else {
|
|
// Exclude workflow types from ready work by default
|
|
// These are internal workflow items, not work for polecats to claim
|
|
switch issue.IssueType {
|
|
case types.TypeMergeRequest, types.TypeGate, types.TypeMolecule, types.TypeMessage:
|
|
continue
|
|
}
|
|
}
|
|
|
|
// Unassigned takes precedence over Assignee filter
|
|
if filter.Unassigned {
|
|
if issue.Assignee != "" {
|
|
continue
|
|
}
|
|
} else if filter.Assignee != nil {
|
|
if issue.Assignee != *filter.Assignee {
|
|
continue
|
|
}
|
|
}
|
|
|
|
// Label filtering (AND semantics)
|
|
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
|
|
}
|
|
}
|
|
|
|
// Label filtering (OR semantics)
|
|
if len(filter.LabelsAny) > 0 {
|
|
issueLabels := m.labels[issue.ID]
|
|
hasAnyLabel := false
|
|
for _, reqLabel := range filter.LabelsAny {
|
|
for _, label := range issueLabels {
|
|
if label == reqLabel {
|
|
hasAnyLabel = true
|
|
break
|
|
}
|
|
}
|
|
if hasAnyLabel {
|
|
break
|
|
}
|
|
}
|
|
if !hasAnyLabel {
|
|
continue
|
|
}
|
|
}
|
|
|
|
// Skip issues with any open 'blocks' dependencies
|
|
if len(m.getOpenBlockers(issue.ID)) > 0 {
|
|
continue
|
|
}
|
|
|
|
issueCopy := *issue
|
|
if deps, ok := m.dependencies[issue.ID]; ok {
|
|
issueCopy.Dependencies = deps
|
|
}
|
|
if labels, ok := m.labels[issue.ID]; ok {
|
|
issueCopy.Labels = labels
|
|
}
|
|
if comments, ok := m.comments[issue.ID]; ok {
|
|
issueCopy.Comments = comments
|
|
}
|
|
|
|
results = append(results, &issueCopy)
|
|
}
|
|
|
|
// Default to hybrid sort for backwards compatibility
|
|
sortPolicy := filter.SortPolicy
|
|
if sortPolicy == "" {
|
|
sortPolicy = types.SortPolicyHybrid
|
|
}
|
|
|
|
switch sortPolicy {
|
|
case types.SortPolicyOldest:
|
|
sort.Slice(results, func(i, j int) bool {
|
|
return results[i].CreatedAt.Before(results[j].CreatedAt)
|
|
})
|
|
case types.SortPolicyPriority:
|
|
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.Before(results[j].CreatedAt)
|
|
})
|
|
case types.SortPolicyHybrid:
|
|
fallthrough
|
|
default:
|
|
cutoff := time.Now().Add(-48 * time.Hour)
|
|
sort.Slice(results, func(i, j int) bool {
|
|
iRecent := results[i].CreatedAt.After(cutoff)
|
|
jRecent := results[j].CreatedAt.After(cutoff)
|
|
if iRecent != jRecent {
|
|
return iRecent // recent first
|
|
}
|
|
if iRecent {
|
|
if results[i].Priority != results[j].Priority {
|
|
return results[i].Priority < results[j].Priority
|
|
}
|
|
}
|
|
return results[i].CreatedAt.Before(results[j].CreatedAt)
|
|
})
|
|
}
|
|
|
|
// Apply limit
|
|
if filter.Limit > 0 && len(results) > filter.Limit {
|
|
results = results[:filter.Limit]
|
|
}
|
|
|
|
return results, nil
|
|
}
|
|
|
|
// getOpenBlockers returns the IDs of blockers that are currently open/in_progress/blocked/deferred.
|
|
// The caller must hold at least a read lock.
|
|
func (m *MemoryStorage) getOpenBlockers(issueID string) []string {
|
|
deps := m.dependencies[issueID]
|
|
if len(deps) == 0 {
|
|
return nil
|
|
}
|
|
|
|
blockers := make([]string, 0)
|
|
for _, dep := range deps {
|
|
if dep.Type != types.DepBlocks {
|
|
continue
|
|
}
|
|
blocker, ok := m.issues[dep.DependsOnID]
|
|
if !ok {
|
|
// If the blocker is missing, treat it as still blocking (data is incomplete)
|
|
blockers = append(blockers, dep.DependsOnID)
|
|
continue
|
|
}
|
|
switch blocker.Status {
|
|
case types.StatusOpen, types.StatusInProgress, types.StatusBlocked, types.StatusDeferred:
|
|
blockers = append(blockers, blocker.ID)
|
|
}
|
|
}
|
|
|
|
sort.Strings(blockers)
|
|
return blockers
|
|
}
|
|
|
|
// GetBlockedIssues returns issues that are blocked by other issues
|
|
// Note: Pinned issues are excluded from the output (beads-ei4)
|
|
func (m *MemoryStorage) GetBlockedIssues(ctx context.Context) ([]*types.BlockedIssue, error) {
|
|
m.mu.RLock()
|
|
defer m.mu.RUnlock()
|
|
|
|
var results []*types.BlockedIssue
|
|
|
|
for _, issue := range m.issues {
|
|
// Only consider non-closed, non-tombstone issues
|
|
if issue.Status == types.StatusClosed || issue.Status == types.StatusTombstone {
|
|
continue
|
|
}
|
|
|
|
// Exclude pinned issues (beads-ei4)
|
|
if issue.Pinned {
|
|
continue
|
|
}
|
|
|
|
blockers := m.getOpenBlockers(issue.ID)
|
|
// Issue is "blocked" if: status is blocked, status is deferred, or has open blockers
|
|
if issue.Status != types.StatusBlocked && issue.Status != types.StatusDeferred && len(blockers) == 0 {
|
|
continue
|
|
}
|
|
|
|
issueCopy := *issue
|
|
if deps, ok := m.dependencies[issue.ID]; ok {
|
|
issueCopy.Dependencies = deps
|
|
}
|
|
if labels, ok := m.labels[issue.ID]; ok {
|
|
issueCopy.Labels = labels
|
|
}
|
|
if comments, ok := m.comments[issue.ID]; ok {
|
|
issueCopy.Comments = comments
|
|
}
|
|
|
|
results = append(results, &types.BlockedIssue{
|
|
Issue: issueCopy,
|
|
BlockedByCount: len(blockers),
|
|
BlockedBy: blockers,
|
|
})
|
|
}
|
|
|
|
// Match SQLite behavior: order by priority ascending
|
|
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.Before(results[j].CreatedAt)
|
|
})
|
|
|
|
return results, nil
|
|
}
|
|
|
|
func (m *MemoryStorage) GetEpicsEligibleForClosure(ctx context.Context) ([]*types.EpicStatus, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func (m *MemoryStorage) GetStaleIssues(ctx context.Context, filter types.StaleFilter) ([]*types.Issue, error) {
|
|
m.mu.RLock()
|
|
defer m.mu.RUnlock()
|
|
|
|
cutoff := time.Now().AddDate(0, 0, -filter.Days)
|
|
var stale []*types.Issue
|
|
|
|
for _, issue := range m.issues {
|
|
if issue.Status == types.StatusClosed {
|
|
continue
|
|
}
|
|
if filter.Status != "" && string(issue.Status) != filter.Status {
|
|
continue
|
|
}
|
|
if issue.UpdatedAt.Before(cutoff) {
|
|
stale = append(stale, issue)
|
|
}
|
|
}
|
|
|
|
// Sort by updated_at ascending (oldest first)
|
|
sort.Slice(stale, func(i, j int) bool {
|
|
return stale[i].UpdatedAt.Before(stale[j].UpdatedAt)
|
|
})
|
|
|
|
if filter.Limit > 0 && len(stale) > filter.Limit {
|
|
stale = stale[:filter.Limit]
|
|
}
|
|
|
|
return stale, 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) GetCommentsForIssues(ctx context.Context, issueIDs []string) (map[string][]*types.Comment, error) {
|
|
m.mu.RLock()
|
|
defer m.mu.RUnlock()
|
|
|
|
result := make(map[string][]*types.Comment)
|
|
for _, issueID := range issueIDs {
|
|
if comments, exists := m.comments[issueID]; exists {
|
|
result[issueID] = comments
|
|
}
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func (m *MemoryStorage) GetStatistics(ctx context.Context) (*types.Statistics, error) {
|
|
m.mu.RLock()
|
|
defer m.mu.RUnlock()
|
|
|
|
stats := &types.Statistics{}
|
|
|
|
// First pass: count by status
|
|
for _, issue := range m.issues {
|
|
switch issue.Status {
|
|
case types.StatusOpen:
|
|
stats.OpenIssues++
|
|
case types.StatusInProgress:
|
|
stats.InProgressIssues++
|
|
case types.StatusClosed:
|
|
stats.ClosedIssues++
|
|
case types.StatusDeferred:
|
|
stats.DeferredIssues++
|
|
case types.StatusTombstone:
|
|
stats.TombstoneIssues++
|
|
case types.StatusPinned:
|
|
stats.PinnedIssues++
|
|
}
|
|
}
|
|
|
|
// TotalIssues excludes tombstones (matches SQLite behavior)
|
|
stats.TotalIssues = stats.OpenIssues + stats.InProgressIssues + stats.ClosedIssues + stats.DeferredIssues + stats.PinnedIssues
|
|
|
|
// Second pass: calculate blocked and ready issues based on dependencies
|
|
// An issue is blocked if it has open blockers (uses same logic as GetBlockedIssues)
|
|
for id, issue := range m.issues {
|
|
// Only consider non-closed, non-tombstone issues for blocking
|
|
if issue.Status == types.StatusClosed || issue.Status == types.StatusTombstone {
|
|
continue
|
|
}
|
|
|
|
blockers := m.getOpenBlockers(id)
|
|
if len(blockers) > 0 {
|
|
stats.BlockedIssues++
|
|
} else if issue.Status == types.StatusOpen {
|
|
// Ready = open issues with no open blockers
|
|
stats.ReadyIssues++
|
|
}
|
|
}
|
|
|
|
// Calculate average lead time (hours from created to closed)
|
|
var totalLeadTime float64
|
|
var closedCount int
|
|
for _, issue := range m.issues {
|
|
if issue.Status == types.StatusClosed && issue.ClosedAt != nil {
|
|
leadTime := issue.ClosedAt.Sub(issue.CreatedAt).Hours()
|
|
totalLeadTime += leadTime
|
|
closedCount++
|
|
}
|
|
}
|
|
if closedCount > 0 {
|
|
stats.AverageLeadTime = totalLeadTime / float64(closedCount)
|
|
}
|
|
|
|
// Calculate epics eligible for closure
|
|
stats.EpicsEligibleForClosure = m.countEpicsEligibleForClosure()
|
|
|
|
return stats, nil
|
|
}
|
|
|
|
// countEpicsEligibleForClosure returns the count of non-closed epics where all children are closed
|
|
func (m *MemoryStorage) countEpicsEligibleForClosure() int {
|
|
// Build a map of epic -> children using parent-child dependencies
|
|
epicChildren := make(map[string][]string)
|
|
for _, deps := range m.dependencies {
|
|
for _, dep := range deps {
|
|
if dep.Type == types.DepParentChild {
|
|
// dep.IssueID is the child, dep.DependsOnID is the parent
|
|
epicChildren[dep.DependsOnID] = append(epicChildren[dep.DependsOnID], dep.IssueID)
|
|
}
|
|
}
|
|
}
|
|
|
|
count := 0
|
|
for epicID, children := range epicChildren {
|
|
epic, exists := m.issues[epicID]
|
|
if !exists {
|
|
continue
|
|
}
|
|
// Only consider non-closed epics
|
|
if epic.IssueType != types.TypeEpic || epic.Status == types.StatusClosed {
|
|
continue
|
|
}
|
|
// Check if all children are closed
|
|
if len(children) == 0 {
|
|
continue
|
|
}
|
|
allClosed := true
|
|
for _, childID := range children {
|
|
child, exists := m.issues[childID]
|
|
if !exists || child.Status != types.StatusClosed {
|
|
allClosed = false
|
|
break
|
|
}
|
|
}
|
|
if allClosed {
|
|
count++
|
|
}
|
|
}
|
|
return count
|
|
}
|
|
|
|
// 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) ClearDirtyIssuesByID(ctx context.Context, issueIDs []string) error {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
|
|
for _, id := range issueIDs {
|
|
delete(m.dirty, id)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// ID Generation
|
|
func (m *MemoryStorage) GetNextChildID(ctx context.Context, parentID string) (string, error) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
|
|
// Validate parent exists
|
|
if _, exists := m.issues[parentID]; !exists {
|
|
return "", fmt.Errorf("parent issue %s does not exist", parentID)
|
|
}
|
|
|
|
// Calculate depth (count dots)
|
|
depth := strings.Count(parentID, ".")
|
|
if depth >= 3 {
|
|
return "", fmt.Errorf("maximum hierarchy depth (3) exceeded for parent %s", parentID)
|
|
}
|
|
|
|
// Get or initialize counter for this parent
|
|
counter := m.counters[parentID]
|
|
counter++
|
|
m.counters[parentID] = counter
|
|
|
|
// Format as parentID.counter
|
|
childID := fmt.Sprintf("%s.%d", parentID, counter)
|
|
return childID, 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
|
|
}
|
|
|
|
// GetCustomStatuses retrieves the list of custom status states from config.
|
|
func (m *MemoryStorage) GetCustomStatuses(ctx context.Context) ([]string, error) {
|
|
value, err := m.GetConfig(ctx, "status.custom")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if value == "" {
|
|
return nil, nil
|
|
}
|
|
return parseCustomStatuses(value), nil
|
|
}
|
|
|
|
// parseCustomStatuses splits a comma-separated string into a slice of trimmed status names.
|
|
func parseCustomStatuses(value string) []string {
|
|
if value == "" {
|
|
return nil
|
|
}
|
|
parts := strings.Split(value, ",")
|
|
result := make([]string, 0, len(parts))
|
|
for _, p := range parts {
|
|
trimmed := strings.TrimSpace(p)
|
|
if trimmed != "" {
|
|
result = append(result, trimmed)
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
// 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")
|
|
}
|
|
|
|
// RunInTransaction executes a function within a transaction context.
|
|
// For MemoryStorage, this provides basic atomicity via mutex locking.
|
|
// If the function returns an error, changes are NOT automatically rolled back
|
|
// since MemoryStorage doesn't support true transaction rollback.
|
|
//
|
|
// Note: For full rollback support, callers should use SQLite storage.
|
|
func (m *MemoryStorage) RunInTransaction(ctx context.Context, fn func(tx storage.Transaction) error) error {
|
|
return fmt.Errorf("RunInTransaction not supported in --no-db mode: use SQLite storage for transaction support")
|
|
}
|
|
|
|
// REMOVED (bd-c7af): SyncAllCounters - no longer needed with hash IDs
|
|
|
|
// 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
|
|
}
|