diff --git a/internal/storage/sqlite/external_deps.go b/internal/storage/sqlite/external_deps.go new file mode 100644 index 00000000..e28dbfd5 --- /dev/null +++ b/internal/storage/sqlite/external_deps.go @@ -0,0 +1,129 @@ +// Package sqlite provides external dependency resolution for cross-project blocking. +// +// External dependencies use the format: external:: +// They are satisfied when: +// - The project is configured in external_projects config +// - The project's beads database has a closed issue with provides: label +// +// Resolution happens lazily at query time (GetReadyWork) rather than during +// cache rebuild, to keep cache rebuilds fast and avoid holding multiple DB connections. +package sqlite + +import ( + "context" + "database/sql" + "path/filepath" + "strings" + + "github.com/steveyegge/beads/internal/config" + "github.com/steveyegge/beads/internal/configfile" +) + +// ExternalDepStatus represents whether an external dependency is satisfied +type ExternalDepStatus struct { + Ref string // The full external reference (external:project:capability) + Project string // Parsed project name + Capability string // Parsed capability name + Satisfied bool // Whether the dependency is satisfied + Reason string // Human-readable reason if not satisfied +} + +// CheckExternalDep checks if a single external dependency is satisfied. +// Returns status information about the dependency. +func CheckExternalDep(ctx context.Context, ref string) *ExternalDepStatus { + status := &ExternalDepStatus{ + Ref: ref, + Satisfied: false, + } + + // Parse external:project:capability + if !strings.HasPrefix(ref, "external:") { + status.Reason = "not an external reference" + return status + } + + parts := strings.SplitN(ref, ":", 3) + if len(parts) != 3 { + status.Reason = "invalid format (expected external:project:capability)" + return status + } + + status.Project = parts[1] + status.Capability = parts[2] + + if status.Project == "" || status.Capability == "" { + status.Reason = "missing project or capability" + return status + } + + // Look up project path from config + projectPath := config.ResolveExternalProjectPath(status.Project) + if projectPath == "" { + status.Reason = "project not configured in external_projects" + return status + } + + // Find the beads database in the project + beadsDir := filepath.Join(projectPath, ".beads") + cfg, err := configfile.Load(beadsDir) + if err != nil || cfg == nil { + status.Reason = "project has no beads database" + return status + } + + dbPath := cfg.DatabasePath(beadsDir) + + // Open the external database (read-only) + db, err := sql.Open("sqlite3", dbPath+"?mode=ro") + if err != nil { + status.Reason = "cannot open project database" + return status + } + defer func() { _ = db.Close() }() + + // Check for a closed issue with provides: label + providesLabel := "provides:" + status.Capability + var count int + err = db.QueryRowContext(ctx, ` + SELECT COUNT(*) FROM issues i + JOIN labels l ON i.id = l.issue_id + WHERE i.status = 'closed' + AND l.label = ? + `, providesLabel).Scan(&count) + + if err != nil { + status.Reason = "database query failed" + return status + } + + if count == 0 { + status.Reason = "capability not shipped (no closed issue with provides:" + status.Capability + " label)" + return status + } + + status.Satisfied = true + status.Reason = "capability shipped" + return status +} + +// CheckExternalDeps checks multiple external dependencies. +// Returns a map of ref -> status. +func CheckExternalDeps(ctx context.Context, refs []string) map[string]*ExternalDepStatus { + results := make(map[string]*ExternalDepStatus) + for _, ref := range refs { + results[ref] = CheckExternalDep(ctx, ref) + } + return results +} + +// GetUnsatisfiedExternalDeps returns external dependencies that are not satisfied. +func GetUnsatisfiedExternalDeps(ctx context.Context, refs []string) []string { + var unsatisfied []string + for _, ref := range refs { + status := CheckExternalDep(ctx, ref) + if !status.Satisfied { + unsatisfied = append(unsatisfied, ref) + } + } + return unsatisfied +}