From e7f09660c07f85c5c88a304c705e67ab183d83f1 Mon Sep 17 00:00:00 2001 From: Steve Yegge Date: Sun, 21 Dec 2025 23:08:00 -0800 Subject: [PATCH] feat: add cross-project dependency support - config and external: prefix (bd-66w1, bd-om4a) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Config (bd-66w1): - Add external_projects config for mapping project names to paths - Add GetExternalProjects() and ResolveExternalProjectPath() functions - Add config documentation and tests External deps (bd-om4a): - bd dep add accepts external:project:capability syntax - External refs stored as-is in dependencies table - GetBlockedIssues includes external deps in blocked_by list - blocked_issues_cache includes external dependencies - Add validation and parsing helpers for external refs 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- cmd/bd/dep.go | 114 +++++++++++++++--- cmd/bd/external_ref_test.go | 110 ++++++++++++++++++ docs/CONFIG.md | 8 ++ internal/config/config.go | 41 +++++++ internal/config/config_test.go | 140 +++++++++++++++++++++++ internal/storage/sqlite/blocked_cache.go | 12 ++ internal/storage/sqlite/ready.go | 26 ++++- 7 files changed, 430 insertions(+), 21 deletions(-) create mode 100644 cmd/bd/external_ref_test.go diff --git a/cmd/bd/dep.go b/cmd/bd/dep.go index 5d023b6c..39dfcdb3 100644 --- a/cmd/bd/dep.go +++ b/cmd/bd/dep.go @@ -24,15 +24,32 @@ var depCmd = &cobra.Command{ var depAddCmd = &cobra.Command{ Use: "add [issue-id] [depends-on-id]", Short: "Add a dependency", - Args: cobra.ExactArgs(2), + Long: `Add a dependency between two issues. + +The depends-on-id can be: + - A local issue ID (e.g., bd-xyz) + - An external reference: external:: + +External references are stored as-is and resolved at query time using +the external_projects config. They block the issue until the capability +is "shipped" in the target project. + +Examples: + bd dep add bd-42 bd-41 # Local dependency + bd dep add gt-xyz external:beads:mol-run-assignee # Cross-project dependency`, + Args: cobra.ExactArgs(2), Run: func(cmd *cobra.Command, args []string) { CheckReadonly("dep add") depType, _ := cmd.Flags().GetString("type") ctx := rootCtx - + // Resolve partial IDs first var fromID, toID string + + // Check if toID is an external reference (don't resolve it) + isExternalRef := strings.HasPrefix(args[1], "external:") + if daemonClient != nil { resolveArgs := &rpc.ResolveIDArgs{ID: args[0]} resp, err := daemonClient.ResolveID(resolveArgs) @@ -44,16 +61,26 @@ var depAddCmd = &cobra.Command{ fmt.Fprintf(os.Stderr, "Error unmarshaling resolved ID: %v\n", err) os.Exit(1) } - - resolveArgs = &rpc.ResolveIDArgs{ID: args[1]} - resp, err = daemonClient.ResolveID(resolveArgs) - if err != nil { - fmt.Fprintf(os.Stderr, "Error resolving dependency ID %s: %v\n", args[1], err) - os.Exit(1) - } - if err := json.Unmarshal(resp.Data, &toID); err != nil { - fmt.Fprintf(os.Stderr, "Error unmarshaling resolved ID: %v\n", err) - os.Exit(1) + + if isExternalRef { + // External references are stored as-is + toID = args[1] + // Validate format: external:: + if err := validateExternalRef(toID); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + } else { + resolveArgs = &rpc.ResolveIDArgs{ID: args[1]} + resp, err = daemonClient.ResolveID(resolveArgs) + if err != nil { + fmt.Fprintf(os.Stderr, "Error resolving dependency ID %s: %v\n", args[1], err) + os.Exit(1) + } + if err := json.Unmarshal(resp.Data, &toID); err != nil { + fmt.Fprintf(os.Stderr, "Error unmarshaling resolved ID: %v\n", err) + os.Exit(1) + } } } else { var err error @@ -62,11 +89,21 @@ var depAddCmd = &cobra.Command{ fmt.Fprintf(os.Stderr, "Error resolving issue ID %s: %v\n", args[0], err) os.Exit(1) } - - toID, err = utils.ResolvePartialID(ctx, store, args[1]) - if err != nil { - fmt.Fprintf(os.Stderr, "Error resolving dependency ID %s: %v\n", args[1], err) - os.Exit(1) + + if isExternalRef { + // External references are stored as-is + toID = args[1] + // Validate format: external:: + if err := validateExternalRef(toID); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + } else { + toID, err = utils.ResolvePartialID(ctx, store, args[1]) + if err != nil { + fmt.Fprintf(os.Stderr, "Error resolving dependency ID %s: %v\n", args[1], err) + os.Exit(1) + } } } @@ -728,6 +765,49 @@ func mergeBidirectionalTrees(downTree, upTree []*types.TreeNode, rootID string) return result } +// validateExternalRef validates the format of an external dependency reference. +// Valid format: external:: +func validateExternalRef(ref string) error { + if !strings.HasPrefix(ref, "external:") { + return fmt.Errorf("external reference must start with 'external:'") + } + + parts := strings.SplitN(ref, ":", 3) + if len(parts) != 3 { + return fmt.Errorf("invalid external reference format: expected 'external::', got '%s'", ref) + } + + project := parts[1] + capability := parts[2] + + if project == "" { + return fmt.Errorf("external reference missing project name") + } + if capability == "" { + return fmt.Errorf("external reference missing capability name") + } + + return nil +} + +// IsExternalRef returns true if the dependency reference is an external reference. +func IsExternalRef(ref string) bool { + return strings.HasPrefix(ref, "external:") +} + +// ParseExternalRef parses an external reference into project and capability. +// Returns empty strings if the format is invalid. +func ParseExternalRef(ref string) (project, capability string) { + if !IsExternalRef(ref) { + return "", "" + } + parts := strings.SplitN(ref, ":", 3) + if len(parts) != 3 { + return "", "" + } + return parts[1], parts[2] +} + func init() { depAddCmd.Flags().StringP("type", "t", "blocks", "Dependency type (blocks|related|parent-child|discovered-from)") // Note: --json flag is defined as a persistent flag in main.go, not here diff --git a/cmd/bd/external_ref_test.go b/cmd/bd/external_ref_test.go new file mode 100644 index 00000000..055576d5 --- /dev/null +++ b/cmd/bd/external_ref_test.go @@ -0,0 +1,110 @@ +package main + +import ( + "testing" +) + +func TestValidateExternalRef(t *testing.T) { + tests := []struct { + name string + ref string + wantErr bool + }{ + { + name: "valid external ref", + ref: "external:beads:mol-run-assignee", + wantErr: false, + }, + { + name: "valid with complex capability", + ref: "external:gastown:cross-project-deps", + wantErr: false, + }, + { + name: "missing external prefix", + ref: "beads:mol-run", + wantErr: true, + }, + { + name: "missing capability", + ref: "external:beads:", + wantErr: true, + }, + { + name: "missing project", + ref: "external::capability", + wantErr: true, + }, + { + name: "only external prefix", + ref: "external:", + wantErr: true, + }, + { + name: "too few parts", + ref: "external:beads", + wantErr: true, + }, + { + name: "local issue ID", + ref: "bd-xyz", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateExternalRef(tt.ref) + if (err != nil) != tt.wantErr { + t.Errorf("validateExternalRef(%q) error = %v, wantErr %v", tt.ref, err, tt.wantErr) + } + }) + } +} + +func TestIsExternalRef(t *testing.T) { + tests := []struct { + ref string + want bool + }{ + {"external:beads:capability", true}, + {"external:", true}, // prefix matches even if invalid + {"bd-xyz", false}, + {"", false}, + {"External:beads:cap", false}, // case-sensitive + } + + for _, tt := range tests { + t.Run(tt.ref, func(t *testing.T) { + if got := IsExternalRef(tt.ref); got != tt.want { + t.Errorf("IsExternalRef(%q) = %v, want %v", tt.ref, got, tt.want) + } + }) + } +} + +func TestParseExternalRef(t *testing.T) { + tests := []struct { + ref string + wantProject string + wantCapability string + }{ + {"external:beads:mol-run-assignee", "beads", "mol-run-assignee"}, + {"external:gastown:cross-project", "gastown", "cross-project"}, + {"external:a:b", "a", "b"}, + {"bd-xyz", "", ""}, // not external + {"external:", "", ""}, // invalid format + {"external:proj", "", ""}, // missing capability + {"", "", ""}, // empty + } + + for _, tt := range tests { + t.Run(tt.ref, func(t *testing.T) { + gotProj, gotCap := ParseExternalRef(tt.ref) + if gotProj != tt.wantProject || gotCap != tt.wantCapability { + t.Errorf("ParseExternalRef(%q) = (%q, %q), want (%q, %q)", + tt.ref, gotProj, gotCap, tt.wantProject, tt.wantCapability) + } + }) + } +} diff --git a/docs/CONFIG.md b/docs/CONFIG.md index 15013019..292143ee 100644 --- a/docs/CONFIG.md +++ b/docs/CONFIG.md @@ -39,6 +39,7 @@ Tool-level settings you can configure: | `git.author` | - | `BD_GIT_AUTHOR` | (none) | Override commit author for beads commits | | `git.no-gpg-sign` | - | `BD_GIT_NO_GPG_SIGN` | `false` | Disable GPG signing for beads commits | | `directory.labels` | - | - | (none) | Map directories to labels for automatic filtering | +| `external_projects` | - | - | (none) | Map project names to paths for cross-project deps | | `db` | `--db` | `BD_DB` | (auto-discover) | Database path | | `actor` | `--actor` | `BD_ACTOR` | `$USER` | Actor name for audit trail | | `flush-debounce` | - | `BEADS_FLUSH_DEBOUNCE` | `5s` | Debounce time for auto-flush | @@ -94,6 +95,13 @@ directory: packages/maverick: maverick packages/agency: agency packages/io: io + +# Cross-project dependency resolution (bd-h807) +# Maps project names to paths for resolving external: blocked_by references +# Paths can be relative (from cwd) or absolute +external_projects: + beads: ../beads + gastown: /path/to/gastown ``` ### Why Two Systems? diff --git a/internal/config/config.go b/internal/config/config.go index 40fb096b..0231fc74 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -120,6 +120,10 @@ func Initialize() error { // Maps directory patterns to labels for automatic filtering in monorepos v.SetDefault("directory.labels", map[string]string{}) + // External projects for cross-project dependency resolution (bd-h807) + // Maps project names to paths for resolving external: blocked_by references + v.SetDefault("external_projects", map[string]string{}) + // Read config file if it was found if configFileSet { if err := v.ReadInConfig(); err != nil { @@ -263,6 +267,43 @@ func GetMultiRepoConfig() *MultiRepoConfig { } } +// GetExternalProjects returns the external_projects configuration. +// Maps project names to paths for cross-project dependency resolution. +// Example config.yaml: +// +// external_projects: +// beads: ../beads +// gastown: /absolute/path/to/gastown +func GetExternalProjects() map[string]string { + return GetStringMapString("external_projects") +} + +// ResolveExternalProjectPath resolves a project name to its absolute path. +// Returns empty string if project not configured or path doesn't exist. +func ResolveExternalProjectPath(projectName string) string { + projects := GetExternalProjects() + path, ok := projects[projectName] + if !ok { + return "" + } + + // Expand relative paths from config file location or cwd + if !filepath.IsAbs(path) { + cwd, err := os.Getwd() + if err != nil { + return "" + } + path = filepath.Join(cwd, path) + } + + // Verify path exists + if _, err := os.Stat(path); err != nil { + return "" + } + + return path +} + // GetIdentity resolves the user's identity for messaging. // Priority chain: // 1. flagValue (if non-empty, from --identity flag) diff --git a/internal/config/config_test.go b/internal/config/config_test.go index a0d20e30..b0d7ee48 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -451,6 +451,146 @@ func TestGetIdentity(t *testing.T) { } } +func TestGetExternalProjects(t *testing.T) { + err := Initialize() + if err != nil { + t.Fatalf("Initialize() returned error: %v", err) + } + + // Test default (empty map) + got := GetExternalProjects() + if got == nil { + t.Error("GetExternalProjects() returned nil, want empty map") + } + if len(got) != 0 { + t.Errorf("GetExternalProjects() = %v, want empty map", got) + } + + // Test with Set + Set("external_projects", map[string]string{ + "beads": "../beads", + "gastown": "/absolute/path/to/gastown", + }) + + got = GetExternalProjects() + if len(got) != 2 { + t.Errorf("GetExternalProjects() has %d items, want 2", len(got)) + } + if got["beads"] != "../beads" { + t.Errorf("GetExternalProjects()[beads] = %q, want \"../beads\"", got["beads"]) + } + if got["gastown"] != "/absolute/path/to/gastown" { + t.Errorf("GetExternalProjects()[gastown] = %q, want \"/absolute/path/to/gastown\"", got["gastown"]) + } +} + +func TestGetExternalProjectsFromConfig(t *testing.T) { + // Create a temporary directory for config file + tmpDir := t.TempDir() + + // Create a config file with external_projects + configContent := ` +external_projects: + beads: ../beads + gastown: /path/to/gastown + other: ./relative/path +` + beadsDir := filepath.Join(tmpDir, ".beads") + if err := os.MkdirAll(beadsDir, 0750); err != nil { + t.Fatalf("failed to create .beads directory: %v", err) + } + + configPath := filepath.Join(beadsDir, "config.yaml") + if err := os.WriteFile(configPath, []byte(configContent), 0600); err != nil { + t.Fatalf("failed to write config file: %v", err) + } + + // Change to tmp directory + t.Chdir(tmpDir) + + // Initialize viper + err := Initialize() + if err != nil { + t.Fatalf("Initialize() returned error: %v", err) + } + + // Test that external_projects is loaded correctly + got := GetExternalProjects() + if len(got) != 3 { + t.Errorf("GetExternalProjects() has %d items, want 3", len(got)) + } + if got["beads"] != "../beads" { + t.Errorf("GetExternalProjects()[beads] = %q, want \"../beads\"", got["beads"]) + } + if got["gastown"] != "/path/to/gastown" { + t.Errorf("GetExternalProjects()[gastown] = %q, want \"/path/to/gastown\"", got["gastown"]) + } + if got["other"] != "./relative/path" { + t.Errorf("GetExternalProjects()[other] = %q, want \"./relative/path\"", got["other"]) + } +} + +func TestResolveExternalProjectPath(t *testing.T) { + // Create a temporary directory structure + tmpDir := t.TempDir() + + // Create a project directory to resolve to + projectDir := filepath.Join(tmpDir, "beads-project") + if err := os.MkdirAll(projectDir, 0750); err != nil { + t.Fatalf("failed to create project directory: %v", err) + } + + // Create config file + beadsDir := filepath.Join(tmpDir, ".beads") + if err := os.MkdirAll(beadsDir, 0750); err != nil { + t.Fatalf("failed to create .beads directory: %v", err) + } + + configContent := ` +external_projects: + beads: beads-project + missing: nonexistent-path + absolute: ` + projectDir + ` +` + configPath := filepath.Join(beadsDir, "config.yaml") + if err := os.WriteFile(configPath, []byte(configContent), 0600); err != nil { + t.Fatalf("failed to write config file: %v", err) + } + + // Change to tmp directory + t.Chdir(tmpDir) + + // Initialize viper + err := Initialize() + if err != nil { + t.Fatalf("Initialize() returned error: %v", err) + } + + // Test resolving a relative path that exists + got := ResolveExternalProjectPath("beads") + if got != projectDir { + t.Errorf("ResolveExternalProjectPath(beads) = %q, want %q", got, projectDir) + } + + // Test resolving a path that doesn't exist + got = ResolveExternalProjectPath("missing") + if got != "" { + t.Errorf("ResolveExternalProjectPath(missing) = %q, want empty string", got) + } + + // Test resolving a project that isn't configured + got = ResolveExternalProjectPath("unknown") + if got != "" { + t.Errorf("ResolveExternalProjectPath(unknown) = %q, want empty string", got) + } + + // Test resolving an absolute path + got = ResolveExternalProjectPath("absolute") + if got != projectDir { + t.Errorf("ResolveExternalProjectPath(absolute) = %q, want %q", got, projectDir) + } +} + func TestGetIdentityFromConfig(t *testing.T) { // Create a temporary directory for config file tmpDir := t.TempDir() diff --git a/internal/storage/sqlite/blocked_cache.go b/internal/storage/sqlite/blocked_cache.go index 65902077..1887ba1e 100644 --- a/internal/storage/sqlite/blocked_cache.go +++ b/internal/storage/sqlite/blocked_cache.go @@ -11,6 +11,7 @@ // The blocked_issues_cache table stores issue_id values for all issues that are currently // blocked. An issue is blocked if: // - It has a 'blocks' dependency on an open/in_progress/blocked issue (direct blocking) +// - It has a 'blocks' dependency on an external:* reference (cross-project blocking, bd-om4a) // - Its parent is blocked and it's connected via 'parent-child' dependency (transitive blocking) // // The cache is maintained automatically by invalidating and rebuilding whenever: @@ -112,16 +113,27 @@ func (s *SQLiteStorage) rebuildBlockedCache(ctx context.Context, exec execer) er } // Rebuild using the recursive CTE logic + // Includes both local blockers (open issues) and external refs (bd-om4a) query := ` INSERT INTO blocked_issues_cache (issue_id) WITH RECURSIVE -- Step 1: Find issues blocked directly by dependencies + -- Includes both local blockers (open issues) and external references blocked_directly AS ( + -- Local blockers: issues with open status SELECT DISTINCT d.issue_id FROM dependencies d JOIN issues blocker ON d.depends_on_id = blocker.id WHERE d.type = 'blocks' AND blocker.status IN ('open', 'in_progress', 'blocked', 'deferred') + + UNION + + -- External blockers: always blocking until resolved (bd-om4a) + SELECT DISTINCT d.issue_id + FROM dependencies d + WHERE d.type = 'blocks' + AND d.depends_on_id LIKE 'external:%' ), -- Step 2: Propagate blockage to all descendants via parent-child diff --git a/internal/storage/sqlite/ready.go b/internal/storage/sqlite/ready.go index a681b385..1f348933 100644 --- a/internal/storage/sqlite/ready.go +++ b/internal/storage/sqlite/ready.go @@ -274,12 +274,17 @@ func (s *SQLiteStorage) GetStaleIssues(ctx context.Context, filter types.StaleFi // GetBlockedIssues returns issues that are blocked by dependencies or have status=blocked // Note: Pinned issues are excluded from the output (beads-ei4) +// Note: Includes external: references in blocked_by list (bd-om4a) func (s *SQLiteStorage) GetBlockedIssues(ctx context.Context) ([]*types.BlockedIssue, error) { // Use UNION to combine: // 1. Issues with open/in_progress/blocked status that have dependency blockers // 2. Issues with status=blocked (even if they have no dependency blockers) // Use GROUP_CONCAT to get all blocker IDs in a single query (no N+1) // Exclude pinned issues (beads-ei4) + // + // For blocked_by_count and blocker_ids: + // - Count local blockers (open issues) + external refs (external:*) + // - External refs are always considered "open" until resolved (bd-om4a) rows, err := s.db.QueryContext(ctx, ` SELECT i.id, i.title, i.description, i.design, i.acceptance_criteria, i.notes, @@ -290,16 +295,22 @@ func (s *SQLiteStorage) GetBlockedIssues(ctx context.Context) ([]*types.BlockedI FROM issues i LEFT JOIN dependencies d ON i.id = d.issue_id AND d.type = 'blocks' - AND EXISTS ( - SELECT 1 FROM issues blocker - WHERE blocker.id = d.depends_on_id - AND blocker.status IN ('open', 'in_progress', 'blocked', 'deferred') + AND ( + -- Local blockers: must be open/in_progress/blocked/deferred + EXISTS ( + SELECT 1 FROM issues blocker + WHERE blocker.id = d.depends_on_id + AND blocker.status IN ('open', 'in_progress', 'blocked', 'deferred') + ) + -- External refs: always included (resolution happens at query time) + OR d.depends_on_id LIKE 'external:%' ) WHERE i.status IN ('open', 'in_progress', 'blocked', 'deferred') AND i.pinned = 0 AND ( i.status = 'blocked' OR i.status = 'deferred' + -- Has local open blockers OR EXISTS ( SELECT 1 FROM dependencies d2 JOIN issues blocker ON d2.depends_on_id = blocker.id @@ -307,6 +318,13 @@ func (s *SQLiteStorage) GetBlockedIssues(ctx context.Context) ([]*types.BlockedI AND d2.type = 'blocks' AND blocker.status IN ('open', 'in_progress', 'blocked', 'deferred') ) + -- Has external blockers (always considered blocking until resolved) + OR EXISTS ( + SELECT 1 FROM dependencies d3 + WHERE d3.issue_id = i.id + AND d3.type = 'blocks' + AND d3.depends_on_id LIKE 'external:%' + ) ) GROUP BY i.id ORDER BY i.priority ASC