Files
beads/internal/storage/memory/memory.go
collins 7cf67153de refactor(types): remove Gas Town type constants from beads core (bd-w2zz4)
Remove Gas Town-specific type constants (TypeMolecule, TypeGate, TypeConvoy,
TypeMergeRequest, TypeSlot, TypeAgent, TypeRole, TypeRig, TypeEvent, TypeMessage)
from internal/types/types.go.

Beads now only has core work types built-in:
- bug, feature, task, epic, chore

All Gas Town types are now purely custom types with no special handling in beads.
Use string literals like "gate" or "molecule" when needed, and configure
types.custom in config.yaml for validation.

Changes:
- Remove Gas Town type constants from types.go
- Remove mr/mol aliases from Normalize()
- Update bd types command to only show core types
- Replace all constant usages with string literals throughout codebase
- Update tests to use string literals

This decouples beads from Gas Town, making it a generic issue tracker.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 10:36:59 -08:00

1834 lines
46 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/config"
"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()
// Get custom types and statuses for validation
var customTypes, customStatuses []string
if typeStr := m.config["types.custom"]; typeStr != "" {
customTypes = parseCustomStatuses(typeStr)
}
if statusStr := m.config["status.custom"]; statusStr != "" {
customStatuses = parseCustomStatuses(statusStr)
}
// Validate with custom types
if err := issue.ValidateWithCustom(customStatuses, customTypes); 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()
// Get custom types and statuses for validation
var customTypes, customStatuses []string
if typeStr := m.config["types.custom"]; typeStr != "" {
customTypes = parseCustomStatuses(typeStr)
}
if statusStr := m.config["status.custom"]; statusStr != "" {
customStatuses = parseCustomStatuses(statusStr)
}
// Validate all first
for i, issue := range issues {
if err := issue.ValidateWithCustom(customStatuses, customTypes); 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
}
case "close_reason":
if v, ok := value.(string); ok {
issue.CloseReason = v
}
case "closed_by_session":
if v, ok := value.(string); ok {
issue.ClosedBySession = v
}
}
}
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.
// The session parameter tracks which Claude Code session closed the issue (can be empty).
func (m *MemoryStorage) CloseIssue(ctx context.Context, id string, reason string, actor string, session string) error {
updates := map[string]interface{}{
"status": string(types.StatusClosed),
"close_reason": reason,
}
if session != "" {
updates["closed_by_session"] = session
}
return m.UpdateIssue(ctx, id, updates, actor)
}
// CreateTombstone converts an existing issue to a tombstone record.
// This is a soft-delete that preserves the issue with status="tombstone".
func (m *MemoryStorage) CreateTombstone(ctx context.Context, id string, actor string, reason string) error {
m.mu.Lock()
defer m.mu.Unlock()
issue, ok := m.issues[id]
if !ok {
return fmt.Errorf("issue not found: %s", id)
}
now := time.Now()
issue.OriginalType = string(issue.IssueType)
issue.Status = types.StatusTombstone
issue.DeletedAt = &now
issue.DeletedBy = actor
issue.DeleteReason = reason
issue.UpdatedAt = now
// Mark as dirty for export
m.dirty[id] = true
// Record tombstone creation event
event := &types.Event{
IssueID: id,
EventType: "deleted",
Actor: actor,
Comment: &reason,
CreatedAt: now,
}
m.events[id] = append(m.events[id], event)
return nil
}
// 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
}
}
// ID prefix filtering (for shell completion)
if filter.IDPrefix != "" {
if !strings.HasPrefix(issue.ID, filter.IDPrefix) {
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
}
// GetDependenciesWithMetadata gets issues that this issue depends on, with dependency type
func (m *MemoryStorage) GetDependenciesWithMetadata(ctx context.Context, issueID string) ([]*types.IssueWithDependencyMetadata, error) {
m.mu.RLock()
defer m.mu.RUnlock()
var results []*types.IssueWithDependencyMetadata
for _, dep := range m.dependencies[issueID] {
if issue, exists := m.issues[dep.DependsOnID]; exists {
issueCopy := *issue
results = append(results, &types.IssueWithDependencyMetadata{
Issue: issueCopy,
DependencyType: dep.Type,
})
}
}
return results, nil
}
// GetDependentsWithMetadata gets issues that depend on this issue, with dependency type
func (m *MemoryStorage) GetDependentsWithMetadata(ctx context.Context, issueID string) ([]*types.IssueWithDependencyMetadata, error) {
m.mu.RLock()
defer m.mu.RUnlock()
var results []*types.IssueWithDependencyMetadata
for id, deps := range m.dependencies {
for _, dep := range deps {
if dep.DependsOnID == issueID {
if issue, exists := m.issues[id]; exists {
issueCopy := *issue
results = append(results, &types.IssueWithDependencyMetadata{
Issue: issueCopy,
DependencyType: dep.Type,
})
}
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) {
// Get the root issue first
root, err := m.GetIssue(ctx, issueID)
if err != nil {
return nil, err
}
if root == nil {
return nil, nil
}
var nodes []*types.TreeNode
// Add root node at depth 0
rootNode := &types.TreeNode{
Depth: 0,
ParentID: issueID, // Root's parent is itself
}
rootNode.ID = root.ID
rootNode.Title = root.Title
rootNode.Description = root.Description
rootNode.Status = root.Status
rootNode.Priority = root.Priority
rootNode.IssueType = root.IssueType
nodes = append(nodes, rootNode)
// Get dependencies (or dependents if reverse)
// Note: reverse mode not fully implemented - uses same logic for now
deps, err := m.GetDependencies(ctx, issueID)
if err != nil {
return nil, err
}
// Add dependencies at depth 1
for _, dep := range deps {
node := &types.TreeNode{
Depth: 1,
ParentID: issueID, // Parent is the root
}
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
// (Gas Town types - not built into beads core)
switch issue.IssueType {
case "merge-request", "gate", "molecule", "message":
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/hooked.
// 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, types.StatusHooked:
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, filter types.WorkFilter) ([]*types.BlockedIssue, error) {
m.mu.RLock()
defer m.mu.RUnlock()
// Build set of descendant IDs if parent filter is specified
var descendantIDs map[string]bool
if filter.ParentID != nil {
descendantIDs = m.getAllDescendants(*filter.ParentID)
}
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
}
// Parent filtering: only include descendants of specified parent
if descendantIDs != nil && !descendantIDs[issue.ID] {
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
}
// IsBlocked checks if an issue is blocked by open dependencies (GH#962).
// Returns true if the issue has open blockers, along with the list of blocker IDs.
func (m *MemoryStorage) IsBlocked(ctx context.Context, issueID string) (bool, []string, error) {
m.mu.RLock()
defer m.mu.RUnlock()
blockers := m.getOpenBlockers(issueID)
if len(blockers) == 0 {
return false, nil, nil
}
return true, blockers, nil
}
// getAllDescendants returns all descendant IDs of a parent issue recursively
func (m *MemoryStorage) getAllDescendants(parentID string) map[string]bool {
descendants := make(map[string]bool)
m.collectDescendants(parentID, descendants)
return descendants
}
// collectDescendants recursively collects all descendants of a parent
func (m *MemoryStorage) collectDescendants(parentID string, descendants map[string]bool) {
for issueID, deps := range m.dependencies {
for _, dep := range deps {
if dep.Type == types.DepParentChild && dep.DependsOnID == parentID {
if !descendants[issueID] {
descendants[issueID] = true
m.collectDescendants(issueID, descendants)
}
}
}
}
}
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
}
// GetNewlyUnblockedByClose returns issues that became unblocked when the given issue was closed.
// This is used by the --suggest-next flag on bd close (GH#679).
func (m *MemoryStorage) GetNewlyUnblockedByClose(ctx context.Context, closedIssueID string) ([]*types.Issue, error) {
m.mu.RLock()
defer m.mu.RUnlock()
var unblocked []*types.Issue
// Find issues that depend on the closed issue
for issueID, deps := range m.dependencies {
issue, exists := m.issues[issueID]
if !exists {
continue
}
// Only consider open/in_progress, non-pinned issues
if issue.Status != types.StatusOpen && issue.Status != types.StatusInProgress {
continue
}
if issue.Pinned {
continue
}
// Check if this issue depended on the closed issue
dependedOnClosed := false
for _, dep := range deps {
if dep.DependsOnID == closedIssueID && dep.Type == types.DepBlocks {
dependedOnClosed = true
break
}
}
if !dependedOnClosed {
continue
}
// Check if now unblocked (no remaining open blockers)
blockers := m.getOpenBlockers(issueID)
if len(blockers) == 0 {
issueCopy := *issue
unblocked = append(unblocked, &issueCopy)
}
}
// Sort by priority ascending
sort.Slice(unblocked, func(i, j int) bool {
return unblocked[i].Priority < unblocked[j].Priority
})
return unblocked, 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)
}
// Check hierarchy depth limit (GH#995)
if err := types.CheckHierarchyDepth(parentID, config.GetInt("hierarchy.max-depth")); err != nil {
return "", err
}
// 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
}
// GetCustomTypes retrieves the list of custom issue types from config.
func (m *MemoryStorage) GetCustomTypes(ctx context.Context) ([]string, error) {
value, err := m.GetConfig(ctx, "types.custom")
if err != nil {
return nil, err
}
if value == "" {
return nil, nil
}
return parseCustomStatuses(value), 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
}
// GetMoleculeProgress returns progress stats for a molecule.
// For memory storage, this iterates through dependencies.
func (m *MemoryStorage) GetMoleculeProgress(ctx context.Context, moleculeID string) (*types.MoleculeProgressStats, error) {
m.mu.RLock()
defer m.mu.RUnlock()
issue, exists := m.issues[moleculeID]
if !exists {
return nil, fmt.Errorf("molecule not found: %s", moleculeID)
}
stats := &types.MoleculeProgressStats{
MoleculeID: moleculeID,
MoleculeTitle: issue.Title,
}
// Find all parent-child dependencies where moleculeID is the parent
for _, deps := range m.dependencies {
for _, dep := range deps {
if dep.Type == types.DepParentChild && dep.DependsOnID == moleculeID {
child, exists := m.issues[dep.IssueID]
if !exists {
continue
}
stats.Total++
switch child.Status {
case types.StatusClosed:
stats.Completed++
if child.ClosedAt != nil {
if stats.FirstClosed == nil || child.ClosedAt.Before(*stats.FirstClosed) {
stats.FirstClosed = child.ClosedAt
}
if stats.LastClosed == nil || child.ClosedAt.After(*stats.LastClosed) {
stats.LastClosed = child.ClosedAt
}
}
case types.StatusInProgress:
stats.InProgress++
if stats.CurrentStepID == "" {
stats.CurrentStepID = child.ID
}
}
}
}
}
return stats, nil
}
// 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
}