Fix #202: Add dependency_type to bd show --json output (#203)

The JSON output from bd show now includes the dependency_type field
for both dependencies and dependents, enabling programmatic
differentiation between dependency types (blocks, related,
parent-child, discovered-from).

Implementation approach:
- Added IssueWithDependencyMetadata type with embedded Issue and
  DependencyType field
- Extended GetDependenciesWithMetadata and GetDependentsWithMetadata
  to include dependency type from SQL JOIN
- Made GetDependencies and GetDependents wrap the WithMetadata
  methods for backward compatibility
- Added scanIssuesWithDependencyType helper to handle scanning with
  dependency type field
- Updated bd show --json to use WithMetadata methods

Tests added:
- TestGetDependenciesWithMetadata - basic functionality
- TestGetDependentsWithMetadata - dependent retrieval
- TestGetDependenciesWithMetadataEmpty - edge case handling
- TestGetDependenciesWithMetadataMultipleTypes - multiple types

Generated with [Claude Code](https://claude.ai/code)
via [Happy](https://happy.engineering)

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Happy <yesreply@happy.engineering>
This commit is contained in:
David Laing
2025-11-02 16:40:10 +00:00
committed by GitHub
parent d240439868
commit 50a324db85
5 changed files with 339 additions and 17 deletions

View File

@@ -199,42 +199,74 @@ func (s *SQLiteStorage) RemoveDependency(ctx context.Context, issueID, dependsOn
return tx.Commit()
}
// GetDependencies returns issues that this issue depends on
func (s *SQLiteStorage) GetDependencies(ctx context.Context, issueID string) ([]*types.Issue, error) {
// GetDependenciesWithMetadata returns issues that this issue depends on, including dependency type
func (s *SQLiteStorage) GetDependenciesWithMetadata(ctx context.Context, issueID string) ([]*types.IssueWithDependencyMetadata, error) {
rows, err := s.db.QueryContext(ctx, `
SELECT i.id, i.content_hash, i.title, i.description, i.design, i.acceptance_criteria, i.notes,
i.status, i.priority, i.issue_type, i.assignee, i.estimated_minutes,
i.created_at, i.updated_at, i.closed_at, i.external_ref
i.created_at, i.updated_at, i.closed_at, i.external_ref,
d.type
FROM issues i
JOIN dependencies d ON i.id = d.depends_on_id
WHERE d.issue_id = ?
ORDER BY i.priority ASC
`, issueID)
if err != nil {
return nil, fmt.Errorf("failed to get dependencies: %w", err)
return nil, fmt.Errorf("failed to get dependencies with metadata: %w", err)
}
defer func() { _ = rows.Close() }()
return s.scanIssues(ctx, rows)
return s.scanIssuesWithDependencyType(ctx, rows)
}
// GetDependents returns issues that depend on this issue
func (s *SQLiteStorage) GetDependents(ctx context.Context, issueID string) ([]*types.Issue, error) {
// GetDependentsWithMetadata returns issues that depend on this issue, including dependency type
func (s *SQLiteStorage) GetDependentsWithMetadata(ctx context.Context, issueID string) ([]*types.IssueWithDependencyMetadata, error) {
rows, err := s.db.QueryContext(ctx, `
SELECT i.id, i.content_hash, i.title, i.description, i.design, i.acceptance_criteria, i.notes,
i.status, i.priority, i.issue_type, i.assignee, i.estimated_minutes,
i.created_at, i.updated_at, i.closed_at, i.external_ref
i.created_at, i.updated_at, i.closed_at, i.external_ref,
d.type
FROM issues i
JOIN dependencies d ON i.id = d.issue_id
WHERE d.depends_on_id = ?
ORDER BY i.priority ASC
`, issueID)
if err != nil {
return nil, fmt.Errorf("failed to get dependents: %w", err)
return nil, fmt.Errorf("failed to get dependents with metadata: %w", err)
}
defer func() { _ = rows.Close() }()
return s.scanIssues(ctx, rows)
return s.scanIssuesWithDependencyType(ctx, rows)
}
// GetDependencies returns issues that this issue depends on
func (s *SQLiteStorage) GetDependencies(ctx context.Context, issueID string) ([]*types.Issue, error) {
issuesWithMeta, err := s.GetDependenciesWithMetadata(ctx, issueID)
if err != nil {
return nil, err
}
// Convert to plain Issue slice for backward compatibility
issues := make([]*types.Issue, len(issuesWithMeta))
for i, iwm := range issuesWithMeta {
issues[i] = &iwm.Issue
}
return issues, nil
}
// GetDependents returns issues that depend on this issue
func (s *SQLiteStorage) GetDependents(ctx context.Context, issueID string) ([]*types.Issue, error) {
issuesWithMeta, err := s.GetDependentsWithMetadata(ctx, issueID)
if err != nil {
return nil, err
}
// Convert to plain Issue slice for backward compatibility
issues := make([]*types.Issue, len(issuesWithMeta))
for i, iwm := range issuesWithMeta {
issues[i] = &iwm.Issue
}
return issues, nil
}
// GetDependencyCounts returns dependency and dependent counts for multiple issues in a single query
@@ -673,3 +705,60 @@ func (s *SQLiteStorage) scanIssues(ctx context.Context, rows *sql.Rows) ([]*type
return issues, nil
}
// Helper function to scan issues with dependency type from rows
func (s *SQLiteStorage) scanIssuesWithDependencyType(ctx context.Context, rows *sql.Rows) ([]*types.IssueWithDependencyMetadata, error) {
var results []*types.IssueWithDependencyMetadata
for rows.Next() {
var issue types.Issue
var contentHash sql.NullString
var closedAt sql.NullTime
var estimatedMinutes sql.NullInt64
var assignee sql.NullString
var externalRef sql.NullString
var depType types.DependencyType
err := rows.Scan(
&issue.ID, &contentHash, &issue.Title, &issue.Description, &issue.Design,
&issue.AcceptanceCriteria, &issue.Notes, &issue.Status,
&issue.Priority, &issue.IssueType, &assignee, &estimatedMinutes,
&issue.CreatedAt, &issue.UpdatedAt, &closedAt, &externalRef,
&depType,
)
if err != nil {
return nil, fmt.Errorf("failed to scan issue with dependency type: %w", err)
}
if contentHash.Valid {
issue.ContentHash = contentHash.String
}
if closedAt.Valid {
issue.ClosedAt = &closedAt.Time
}
if estimatedMinutes.Valid {
mins := int(estimatedMinutes.Int64)
issue.EstimatedMinutes = &mins
}
if assignee.Valid {
issue.Assignee = assignee.String
}
if externalRef.Valid {
issue.ExternalRef = &externalRef.String
}
// Fetch labels for this issue
labels, err := s.GetLabels(ctx, issue.ID)
if err != nil {
return nil, fmt.Errorf("failed to get labels for issue %s: %w", issue.ID, err)
}
issue.Labels = labels
result := &types.IssueWithDependencyMetadata{
Issue: issue,
DependencyType: depType,
}
results = append(results, result)
}
return results, nil
}

View File

@@ -1008,3 +1008,210 @@ func TestGetDependencyCountsNonexistent(t *testing.T) {
}
}
}
func TestGetDependenciesWithMetadata(t *testing.T) {
store, cleanup := setupTestDB(t)
defer cleanup()
ctx := context.Background()
// Create issues
issue1 := &types.Issue{Title: "Foundation", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask}
issue2 := &types.Issue{Title: "Feature A", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask}
issue3 := &types.Issue{Title: "Feature B", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask}
store.CreateIssue(ctx, issue1, "test-user")
store.CreateIssue(ctx, issue2, "test-user")
store.CreateIssue(ctx, issue3, "test-user")
// Add dependencies with different types
// issue2 depends on issue1 (blocks)
store.AddDependency(ctx, &types.Dependency{
IssueID: issue2.ID,
DependsOnID: issue1.ID,
Type: types.DepBlocks,
}, "test-user")
// issue3 depends on issue1 (discovered-from)
store.AddDependency(ctx, &types.Dependency{
IssueID: issue3.ID,
DependsOnID: issue1.ID,
Type: types.DepDiscoveredFrom,
}, "test-user")
// Get dependencies with metadata for issue2
deps, err := store.GetDependenciesWithMetadata(ctx, issue2.ID)
if err != nil {
t.Fatalf("GetDependenciesWithMetadata failed: %v", err)
}
if len(deps) != 1 {
t.Fatalf("Expected 1 dependency, got %d", len(deps))
}
// Verify the dependency includes type metadata
dep := deps[0]
if dep.ID != issue1.ID {
t.Errorf("Expected dependency on %s, got %s", issue1.ID, dep.ID)
}
if dep.DependencyType != types.DepBlocks {
t.Errorf("Expected dependency type 'blocks', got %s", dep.DependencyType)
}
if dep.Title != "Foundation" {
t.Errorf("Expected title 'Foundation', got %s", dep.Title)
}
// Get dependencies with metadata for issue3
deps3, err := store.GetDependenciesWithMetadata(ctx, issue3.ID)
if err != nil {
t.Fatalf("GetDependenciesWithMetadata failed: %v", err)
}
if len(deps3) != 1 {
t.Fatalf("Expected 1 dependency, got %d", len(deps3))
}
// Verify the dependency type is discovered-from
if deps3[0].DependencyType != types.DepDiscoveredFrom {
t.Errorf("Expected dependency type 'discovered-from', got %s", deps3[0].DependencyType)
}
}
func TestGetDependentsWithMetadata(t *testing.T) {
store, cleanup := setupTestDB(t)
defer cleanup()
ctx := context.Background()
// Create issues: issue2 and issue3 both depend on issue1
issue1 := &types.Issue{Title: "Foundation", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask}
issue2 := &types.Issue{Title: "Feature A", Status: types.StatusOpen, Priority: 2, IssueType: types.TypeTask}
issue3 := &types.Issue{Title: "Feature B", Status: types.StatusOpen, Priority: 3, IssueType: types.TypeTask}
store.CreateIssue(ctx, issue1, "test-user")
store.CreateIssue(ctx, issue2, "test-user")
store.CreateIssue(ctx, issue3, "test-user")
// Add dependencies with different types
store.AddDependency(ctx, &types.Dependency{
IssueID: issue2.ID,
DependsOnID: issue1.ID,
Type: types.DepBlocks,
}, "test-user")
store.AddDependency(ctx, &types.Dependency{
IssueID: issue3.ID,
DependsOnID: issue1.ID,
Type: types.DepRelated,
}, "test-user")
// Get dependents of issue1 with metadata
dependents, err := store.GetDependentsWithMetadata(ctx, issue1.ID)
if err != nil {
t.Fatalf("GetDependentsWithMetadata failed: %v", err)
}
if len(dependents) != 2 {
t.Fatalf("Expected 2 dependents, got %d", len(dependents))
}
// Verify dependents are ordered by priority (issue2=P1 before issue3=P2)
if dependents[0].ID != issue2.ID {
t.Errorf("Expected first dependent to be %s, got %s", issue2.ID, dependents[0].ID)
}
if dependents[0].DependencyType != types.DepBlocks {
t.Errorf("Expected first dependent type 'blocks', got %s", dependents[0].DependencyType)
}
if dependents[1].ID != issue3.ID {
t.Errorf("Expected second dependent to be %s, got %s", issue3.ID, dependents[1].ID)
}
if dependents[1].DependencyType != types.DepRelated {
t.Errorf("Expected second dependent type 'related', got %s", dependents[1].DependencyType)
}
}
func TestGetDependenciesWithMetadataEmpty(t *testing.T) {
store, cleanup := setupTestDB(t)
defer cleanup()
ctx := context.Background()
// Create issue with no dependencies
issue := &types.Issue{Title: "Standalone", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask}
store.CreateIssue(ctx, issue, "test-user")
// Get dependencies with metadata
deps, err := store.GetDependenciesWithMetadata(ctx, issue.ID)
if err != nil {
t.Fatalf("GetDependenciesWithMetadata failed: %v", err)
}
if len(deps) != 0 {
t.Errorf("Expected 0 dependencies, got %d", len(deps))
}
}
func TestGetDependenciesWithMetadataMultipleTypes(t *testing.T) {
store, cleanup := setupTestDB(t)
defer cleanup()
ctx := context.Background()
// Create issues
base := &types.Issue{Title: "Base", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask}
blocks := &types.Issue{Title: "Blocker", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask}
related := &types.Issue{Title: "Related", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask}
discovered := &types.Issue{Title: "Discovered", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask}
store.CreateIssue(ctx, base, "test-user")
store.CreateIssue(ctx, blocks, "test-user")
store.CreateIssue(ctx, related, "test-user")
store.CreateIssue(ctx, discovered, "test-user")
// Add dependencies of different types
store.AddDependency(ctx, &types.Dependency{
IssueID: base.ID,
DependsOnID: blocks.ID,
Type: types.DepBlocks,
}, "test-user")
store.AddDependency(ctx, &types.Dependency{
IssueID: base.ID,
DependsOnID: related.ID,
Type: types.DepRelated,
}, "test-user")
store.AddDependency(ctx, &types.Dependency{
IssueID: base.ID,
DependsOnID: discovered.ID,
Type: types.DepDiscoveredFrom,
}, "test-user")
// Get all dependencies with metadata
deps, err := store.GetDependenciesWithMetadata(ctx, base.ID)
if err != nil {
t.Fatalf("GetDependenciesWithMetadata failed: %v", err)
}
if len(deps) != 3 {
t.Fatalf("Expected 3 dependencies, got %d", len(deps))
}
// Create a map of dependency types
typeMap := make(map[string]types.DependencyType)
for _, dep := range deps {
typeMap[dep.ID] = dep.DependencyType
}
// Verify all types are correctly returned
if typeMap[blocks.ID] != types.DepBlocks {
t.Errorf("Expected blocks dependency type 'blocks', got %s", typeMap[blocks.ID])
}
if typeMap[related.ID] != types.DepRelated {
t.Errorf("Expected related dependency type 'related', got %s", typeMap[related.ID])
}
if typeMap[discovered.ID] != types.DepDiscoveredFrom {
t.Errorf("Expected discovered dependency type 'discovered-from', got %s", typeMap[discovered.ID])
}
}