feat: add bd mol progress command for efficient molecule monitoring (bd-8xnf)
Adds a new `bd mol progress` command that shows molecule progress using indexed queries instead of loading all steps into memory. This makes it suitable for mega-molecules with millions of steps. Features: - Efficient SQL-based counting via idx_dependencies_depends_on_type index - Progress display: completed / total (percentage) - Current step identification - Rate calculation from closure timestamps - ETA estimation - JSON output support New storage interface method: GetMoleculeProgress(ctx, moleculeID) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
committed by
Steve Yegge
parent
3593ba8d66
commit
0a96b10bba
@@ -1663,6 +1663,55 @@ 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
|
||||
|
||||
@@ -207,3 +207,74 @@ func (s *SQLiteStorage) GetStatistics(ctx context.Context) (*types.Statistics, e
|
||||
|
||||
return &stats, nil
|
||||
}
|
||||
|
||||
// GetMoleculeProgress returns efficient progress stats for a molecule.
|
||||
// Uses indexed queries on dependencies table instead of loading all steps.
|
||||
func (s *SQLiteStorage) GetMoleculeProgress(ctx context.Context, moleculeID string) (*types.MoleculeProgressStats, error) {
|
||||
// First get the molecule's title
|
||||
var title string
|
||||
err := s.db.QueryRowContext(ctx, `SELECT title FROM issues WHERE id = ?`, moleculeID).Scan(&title)
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, fmt.Errorf("molecule not found: %s", moleculeID)
|
||||
}
|
||||
return nil, fmt.Errorf("failed to get molecule: %w", err)
|
||||
}
|
||||
|
||||
stats := &types.MoleculeProgressStats{
|
||||
MoleculeID: moleculeID,
|
||||
MoleculeTitle: title,
|
||||
}
|
||||
|
||||
// Get counts from direct children via parent-child dependency
|
||||
// Uses idx_dependencies_depends_on_type index
|
||||
err = s.db.QueryRowContext(ctx, `
|
||||
SELECT
|
||||
COUNT(*) as total,
|
||||
COALESCE(SUM(CASE WHEN i.status = 'closed' THEN 1 ELSE 0 END), 0) as completed,
|
||||
COALESCE(SUM(CASE WHEN i.status = 'in_progress' THEN 1 ELSE 0 END), 0) as in_progress
|
||||
FROM dependencies d
|
||||
JOIN issues i ON d.issue_id = i.id
|
||||
WHERE d.depends_on_id = ? AND d.type = 'parent-child'
|
||||
`, moleculeID).Scan(&stats.Total, &stats.Completed, &stats.InProgress)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get child counts: %w", err)
|
||||
}
|
||||
|
||||
// Get first in_progress step ID (for "current step" display)
|
||||
var currentStepID sql.NullString
|
||||
err = s.db.QueryRowContext(ctx, `
|
||||
SELECT i.id
|
||||
FROM dependencies d
|
||||
JOIN issues i ON d.issue_id = i.id
|
||||
WHERE d.depends_on_id = ? AND d.type = 'parent-child' AND i.status = 'in_progress'
|
||||
ORDER BY i.created_at
|
||||
LIMIT 1
|
||||
`, moleculeID).Scan(¤tStepID)
|
||||
if err != nil && err != sql.ErrNoRows {
|
||||
return nil, fmt.Errorf("failed to get current step: %w", err)
|
||||
}
|
||||
if currentStepID.Valid {
|
||||
stats.CurrentStepID = currentStepID.String
|
||||
}
|
||||
|
||||
// Get first and last closure times for rate calculation
|
||||
var firstClosed, lastClosed sql.NullTime
|
||||
err = s.db.QueryRowContext(ctx, `
|
||||
SELECT MIN(i.closed_at), MAX(i.closed_at)
|
||||
FROM dependencies d
|
||||
JOIN issues i ON d.issue_id = i.id
|
||||
WHERE d.depends_on_id = ? AND d.type = 'parent-child' AND i.status = 'closed'
|
||||
`, moleculeID).Scan(&firstClosed, &lastClosed)
|
||||
if err != nil && err != sql.ErrNoRows {
|
||||
return nil, fmt.Errorf("failed to get closure times: %w", err)
|
||||
}
|
||||
if firstClosed.Valid {
|
||||
stats.FirstClosed = &firstClosed.Time
|
||||
}
|
||||
if lastClosed.Valid {
|
||||
stats.LastClosed = &lastClosed.Time
|
||||
}
|
||||
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
@@ -126,6 +126,9 @@ type Storage interface {
|
||||
// Statistics
|
||||
GetStatistics(ctx context.Context) (*types.Statistics, error)
|
||||
|
||||
// Molecule progress (efficient for large molecules)
|
||||
GetMoleculeProgress(ctx context.Context, moleculeID string) (*types.MoleculeProgressStats, error)
|
||||
|
||||
// Dirty tracking (for incremental JSONL export)
|
||||
GetDirtyIssues(ctx context.Context) ([]string, error)
|
||||
GetDirtyIssueHash(ctx context.Context, issueID string) (string, error) // For timestamp-only dedup (bd-164)
|
||||
|
||||
@@ -125,6 +125,9 @@ func (m *mockStorage) GetCommentsForIssues(ctx context.Context, issueIDs []strin
|
||||
func (m *mockStorage) GetStatistics(ctx context.Context) (*types.Statistics, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (m *mockStorage) GetMoleculeProgress(ctx context.Context, moleculeID string) (*types.MoleculeProgressStats, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (m *mockStorage) GetDirtyIssues(ctx context.Context) ([]string, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
@@ -677,6 +677,19 @@ type TreeNode struct {
|
||||
Truncated bool `json:"truncated"`
|
||||
}
|
||||
|
||||
// MoleculeProgressStats provides efficient progress info for large molecules.
|
||||
// This uses indexed queries instead of loading all steps into memory.
|
||||
type MoleculeProgressStats struct {
|
||||
MoleculeID string `json:"molecule_id"`
|
||||
MoleculeTitle string `json:"molecule_title"`
|
||||
Total int `json:"total"` // Total steps (direct children)
|
||||
Completed int `json:"completed"` // Closed steps
|
||||
InProgress int `json:"in_progress"` // Steps currently in progress
|
||||
CurrentStepID string `json:"current_step_id"` // First in_progress step ID (if any)
|
||||
FirstClosed *time.Time `json:"first_closed,omitempty"`
|
||||
LastClosed *time.Time `json:"last_closed,omitempty"`
|
||||
}
|
||||
|
||||
// Statistics provides aggregate metrics
|
||||
type Statistics struct {
|
||||
TotalIssues int `json:"total_issues"`
|
||||
|
||||
Reference in New Issue
Block a user