Merge branch 'main' of github.com:steveyegge/beads

This commit is contained in:
Steve Yegge
2025-11-01 20:02:24 -07:00
12 changed files with 320 additions and 25 deletions
+43 -15
View File
@@ -119,27 +119,34 @@ var listCmd = &cobra.Command{
os.Exit(1)
}
if jsonOutput {
// For JSON output, preserve the full response with counts
var issuesWithCounts []*types.IssueWithCounts
if err := json.Unmarshal(resp.Data, &issuesWithCounts); err != nil {
fmt.Fprintf(os.Stderr, "Error parsing response: %v\n", err)
os.Exit(1)
}
outputJSON(issuesWithCounts)
return
}
var issues []*types.Issue
if err := json.Unmarshal(resp.Data, &issues); err != nil {
fmt.Fprintf(os.Stderr, "Error parsing response: %v\n", err)
os.Exit(1)
}
if jsonOutput {
outputJSON(issues)
} else {
fmt.Printf("\nFound %d issues:\n\n", len(issues))
for _, issue := range issues {
fmt.Printf("%s [P%d] [%s] %s\n", issue.ID, issue.Priority, issue.IssueType, issue.Status)
fmt.Printf(" %s\n", issue.Title)
if issue.Assignee != "" {
fmt.Printf(" Assignee: %s\n", issue.Assignee)
}
if len(issue.Labels) > 0 {
fmt.Printf(" Labels: %v\n", issue.Labels)
}
fmt.Println()
fmt.Printf("\nFound %d issues:\n\n", len(issues))
for _, issue := range issues {
fmt.Printf("%s [P%d] [%s] %s\n", issue.ID, issue.Priority, issue.IssueType, issue.Status)
fmt.Printf(" %s\n", issue.Title)
if issue.Assignee != "" {
fmt.Printf(" Assignee: %s\n", issue.Assignee)
}
if len(issue.Labels) > 0 {
fmt.Printf(" Labels: %v\n", issue.Labels)
}
fmt.Println()
}
return
}
@@ -178,7 +185,28 @@ var listCmd = &cobra.Command{
for _, issue := range issues {
issue.Labels, _ = store.GetLabels(ctx, issue.ID)
}
outputJSON(issues)
// Get dependency counts in bulk (single query instead of N queries)
issueIDs := make([]string, len(issues))
for i, issue := range issues {
issueIDs[i] = issue.ID
}
depCounts, _ := store.GetDependencyCounts(ctx, issueIDs)
// Build response with counts
issuesWithCounts := make([]*types.IssueWithCounts, len(issues))
for i, issue := range issues {
counts := depCounts[issue.ID]
if counts == nil {
counts = &types.DependencyCounts{DependencyCount: 0, DependentCount: 0}
}
issuesWithCounts[i] = &types.IssueWithCounts{
Issue: issue,
DependencyCount: counts.DependencyCount,
DependentCount: counts.DependentCount,
}
}
outputJSON(issuesWithCounts)
return
}
+3 -3
View File
@@ -173,11 +173,11 @@ func detectPrefix(beadsDir string, memStore *memory.MemoryStorage) (string, erro
// extractIssuePrefix extracts the prefix from an issue ID like "bd-123" -> "bd"
func extractIssuePrefix(issueID string) string {
parts := strings.SplitN(issueID, "-", 2)
if len(parts) < 2 {
idx := strings.LastIndex(issueID, "-")
if idx <= 0 {
return ""
}
return parts[0]
return issueID[:idx]
}
// writeIssuesToJSONL writes all issues from memory storage to JSONL file atomically
+1
View File
@@ -18,6 +18,7 @@ func TestExtractIssuePrefix(t *testing.T) {
{"standard ID", "bd-123", "bd"},
{"custom prefix", "myproject-456", "myproject"},
{"hash ID", "bd-abc123def", "bd"},
{"hyphenated prefix", "alpha-beta-1", "alpha-beta"},
{"no hyphen", "nohyphen", ""},
{"empty", "", ""},
}
@@ -31,6 +31,8 @@ class Issue(BaseModel):
labels: list[str] = Field(default_factory=list)
dependencies: list["Issue"] = Field(default_factory=list)
dependents: list["Issue"] = Field(default_factory=list)
dependency_count: int = 0
dependent_count: int = 0
@field_validator("priority")
@classmethod
+22 -1
View File
@@ -313,7 +313,28 @@ func (s *Server) handleList(req *Request) Response {
issue.Labels = labels
}
data, _ := json.Marshal(issues)
// Get dependency counts in bulk (single query instead of N queries)
issueIDs := make([]string, len(issues))
for i, issue := range issues {
issueIDs[i] = issue.ID
}
depCounts, _ := store.GetDependencyCounts(ctx, issueIDs)
// Build response with counts
issuesWithCounts := make([]*types.IssueWithCounts, len(issues))
for i, issue := range issues {
counts := depCounts[issue.ID]
if counts == nil {
counts = &types.DependencyCounts{DependencyCount: 0, DependentCount: 0}
}
issuesWithCounts[i] = &types.IssueWithCounts{
Issue: issue,
DependencyCount: counts.DependencyCount,
DependentCount: counts.DependentCount,
}
}
data, _ := json.Marshal(issuesWithCounts)
return Response{
Success: true,
Data: data,
+40
View File
@@ -555,6 +555,46 @@ func (m *MemoryStorage) GetDependents(ctx context.Context, issueID string) ([]*t
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()
+74
View File
@@ -237,6 +237,80 @@ func (s *SQLiteStorage) GetDependents(ctx context.Context, issueID string) ([]*t
return s.scanIssues(ctx, rows)
}
// GetDependencyCounts returns dependency and dependent counts for multiple issues in a single query
func (s *SQLiteStorage) GetDependencyCounts(ctx context.Context, issueIDs []string) (map[string]*types.DependencyCounts, error) {
if len(issueIDs) == 0 {
return make(map[string]*types.DependencyCounts), nil
}
// Build placeholders for the IN clause
placeholders := make([]string, len(issueIDs))
args := make([]interface{}, len(issueIDs)*2)
for i, id := range issueIDs {
placeholders[i] = "?"
args[i] = id
args[len(issueIDs)+i] = id
}
inClause := strings.Join(placeholders, ",")
// Single query that counts both dependencies and dependents
// Uses UNION ALL to combine results from both directions
query := fmt.Sprintf(`
SELECT
issue_id,
SUM(CASE WHEN type = 'dependency' THEN count ELSE 0 END) as dependency_count,
SUM(CASE WHEN type = 'dependent' THEN count ELSE 0 END) as dependent_count
FROM (
-- Count dependencies (issues this issue depends on)
SELECT issue_id, 'dependency' as type, COUNT(*) as count
FROM dependencies
WHERE issue_id IN (%s)
GROUP BY issue_id
UNION ALL
-- Count dependents (issues that depend on this issue)
SELECT depends_on_id as issue_id, 'dependent' as type, COUNT(*) as count
FROM dependencies
WHERE depends_on_id IN (%s)
GROUP BY depends_on_id
)
GROUP BY issue_id
`, inClause, inClause)
rows, err := s.db.QueryContext(ctx, query, args...)
if err != nil {
return nil, fmt.Errorf("failed to get dependency counts: %w", err)
}
defer func() { _ = rows.Close() }()
result := make(map[string]*types.DependencyCounts)
for rows.Next() {
var issueID string
var counts types.DependencyCounts
if err := rows.Scan(&issueID, &counts.DependencyCount, &counts.DependentCount); err != nil {
return nil, fmt.Errorf("failed to scan dependency counts: %w", err)
}
result[issueID] = &counts
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("error iterating dependency counts: %w", err)
}
// Fill in zero counts for issues with no dependencies or dependents
for _, id := range issueIDs {
if _, exists := result[id]; !exists {
result[id] = &types.DependencyCounts{
DependencyCount: 0,
DependentCount: 0,
}
}
}
return result, nil
}
// GetDependencyRecords returns raw dependency records for an issue
func (s *SQLiteStorage) GetDependencyRecords(ctx context.Context, issueID string) ([]*types.Dependency, error) {
rows, err := s.db.QueryContext(ctx, `
@@ -903,3 +903,108 @@ func TestGetDependencyTree_SubstringBug(t *testing.T) {
t.Errorf("Expected bd-1 at depth 4, got %d", depthMap[issues[0].ID])
}
}
func TestGetDependencyCounts(t *testing.T) {
store, cleanup := setupTestDB(t)
defer cleanup()
ctx := context.Background()
// Create a network of issues with dependencies
// A (depends on B, C)
// B (depends on C)
// C (no dependencies)
// D (depends on A)
// E (no dependencies, no dependents)
issueA := &types.Issue{Title: "Task A", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask}
issueB := &types.Issue{Title: "Task B", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask}
issueC := &types.Issue{Title: "Task C", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask}
issueD := &types.Issue{Title: "Task D", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask}
issueE := &types.Issue{Title: "Task E", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask}
store.CreateIssue(ctx, issueA, "test-user")
store.CreateIssue(ctx, issueB, "test-user")
store.CreateIssue(ctx, issueC, "test-user")
store.CreateIssue(ctx, issueD, "test-user")
store.CreateIssue(ctx, issueE, "test-user")
// Add dependencies
store.AddDependency(ctx, &types.Dependency{IssueID: issueA.ID, DependsOnID: issueB.ID, Type: types.DepBlocks}, "test-user")
store.AddDependency(ctx, &types.Dependency{IssueID: issueA.ID, DependsOnID: issueC.ID, Type: types.DepBlocks}, "test-user")
store.AddDependency(ctx, &types.Dependency{IssueID: issueB.ID, DependsOnID: issueC.ID, Type: types.DepBlocks}, "test-user")
store.AddDependency(ctx, &types.Dependency{IssueID: issueD.ID, DependsOnID: issueA.ID, Type: types.DepBlocks}, "test-user")
// Get counts for all issues
issueIDs := []string{issueA.ID, issueB.ID, issueC.ID, issueD.ID, issueE.ID}
counts, err := store.GetDependencyCounts(ctx, issueIDs)
if err != nil {
t.Fatalf("GetDependencyCounts failed: %v", err)
}
// Verify counts
testCases := []struct {
issueID string
name string
expectedDeps int
expectedDepents int
}{
{issueA.ID, "A", 2, 1}, // depends on B and C, D depends on A
{issueB.ID, "B", 1, 1}, // depends on C, A depends on B
{issueC.ID, "C", 0, 2}, // no dependencies, A and B depend on C
{issueD.ID, "D", 1, 0}, // depends on A, nothing depends on D
{issueE.ID, "E", 0, 0}, // isolated issue
}
for _, tc := range testCases {
count := counts[tc.issueID]
if count == nil {
t.Errorf("Issue %s (%s): no counts returned", tc.name, tc.issueID)
continue
}
if count.DependencyCount != tc.expectedDeps {
t.Errorf("Issue %s (%s): expected %d dependencies, got %d",
tc.name, tc.issueID, tc.expectedDeps, count.DependencyCount)
}
if count.DependentCount != tc.expectedDepents {
t.Errorf("Issue %s (%s): expected %d dependents, got %d",
tc.name, tc.issueID, tc.expectedDepents, count.DependentCount)
}
}
}
func TestGetDependencyCountsEmpty(t *testing.T) {
store, cleanup := setupTestDB(t)
defer cleanup()
ctx := context.Background()
// Test with empty list
counts, err := store.GetDependencyCounts(ctx, []string{})
if err != nil {
t.Fatalf("GetDependencyCounts failed on empty list: %v", err)
}
if len(counts) != 0 {
t.Errorf("Expected empty map for empty input, got %d entries", len(counts))
}
}
func TestGetDependencyCountsNonexistent(t *testing.T) {
store, cleanup := setupTestDB(t)
defer cleanup()
ctx := context.Background()
// Test with non-existent issue IDs
counts, err := store.GetDependencyCounts(ctx, []string{"fake-1", "fake-2"})
if err != nil {
t.Fatalf("GetDependencyCounts failed on nonexistent IDs: %v", err)
}
// Should return zero counts for non-existent issues
for id, count := range counts {
if count.DependencyCount != 0 || count.DependentCount != 0 {
t.Errorf("Expected zero counts for nonexistent issue %s, got deps=%d, dependents=%d",
id, count.DependencyCount, count.DependentCount)
}
}
}
+1
View File
@@ -25,6 +25,7 @@ type Storage interface {
GetDependents(ctx context.Context, issueID string) ([]*types.Issue, error)
GetDependencyRecords(ctx context.Context, issueID string) ([]*types.Dependency, error)
GetAllDependencyRecords(ctx context.Context) (map[string][]*types.Dependency, error)
GetDependencyCounts(ctx context.Context, issueIDs []string) (map[string]*types.DependencyCounts, error)
GetDependencyTree(ctx context.Context, issueID string, maxDepth int, showAllPaths bool, reverse bool) ([]*types.TreeNode, error)
DetectCycles(ctx context.Context) ([][]*types.Issue, error)
+13
View File
@@ -147,6 +147,19 @@ type Dependency struct {
CreatedBy string `json:"created_by"`
}
// DependencyCounts holds counts for dependencies and dependents
type DependencyCounts struct {
DependencyCount int `json:"dependency_count"` // Number of issues this issue depends on
DependentCount int `json:"dependent_count"` // Number of issues that depend on this issue
}
// IssueWithCounts extends Issue with dependency relationship counts
type IssueWithCounts struct {
*Issue
DependencyCount int `json:"dependency_count"`
DependentCount int `json:"dependent_count"`
}
// DependencyType categorizes the relationship
type DependencyType string
+10
View File
@@ -321,6 +321,11 @@ func TestExtractIssuePrefix(t *testing.T) {
issueID: "bd-",
expected: "bd",
},
{
name: "hyphenated prefix",
issueID: "alpha-beta-1",
expected: "alpha-beta",
},
}
for _, tt := range tests {
@@ -379,6 +384,11 @@ func TestExtractIssueNumber(t *testing.T) {
issueID: "bd-123abc",
expected: 123,
},
{
name: "hyphenated prefix with number",
issueID: "alpha-beta-7",
expected: 7,
},
}
for _, tt := range tests {
+6 -6
View File
@@ -7,20 +7,20 @@ import (
// ExtractIssuePrefix extracts the prefix from an issue ID like "bd-123" -> "bd"
func ExtractIssuePrefix(issueID string) string {
parts := strings.SplitN(issueID, "-", 2)
if len(parts) < 2 {
idx := strings.LastIndex(issueID, "-")
if idx <= 0 {
return ""
}
return parts[0]
return issueID[:idx]
}
// ExtractIssueNumber extracts the number from an issue ID like "bd-123" -> 123
func ExtractIssueNumber(issueID string) int {
parts := strings.SplitN(issueID, "-", 2)
if len(parts) < 2 {
idx := strings.LastIndex(issueID, "-")
if idx < 0 || idx == len(issueID)-1 {
return 0
}
var num int
fmt.Sscanf(parts[1], "%d", &num)
fmt.Sscanf(issueID[idx+1:], "%d", &num)
return num
}