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:
114
cmd/bd/dep.go
114
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:<project>:<capability>
|
||||
|
||||
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:<project>:<capability>
|
||||
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:<project>:<capability>
|
||||
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:<project>:<capability>
|
||||
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:<project>:<capability>', 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
|
||||
|
||||
110
cmd/bd/external_ref_test.go
Normal file
110
cmd/bd/external_ref_test.go
Normal file
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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?
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user