feat: add cross-project dependency support - config and external: prefix (bd-66w1, bd-om4a)

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 <noreply@anthropic.com>
This commit is contained in:
Steve Yegge
2025-12-21 23:08:00 -08:00
parent fe6fec437a
commit e7f09660c0
7 changed files with 430 additions and 21 deletions

View File

@@ -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)

View File

@@ -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()