Skip to content

Commit f4e4ef9

Browse files
marcusclaude
andcommitted
feat: auto-unblock dependents when blocker is approved/closed
When a blocking issue is closed (via approve or close), td now automatically transitions dependent issues from blocked → open if all their dependencies are resolved. Includes fix for GetBlockedBy missing relation_type filter and conservative handling of missing deps. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 0b8ddd4 commit f4e4ef9

7 files changed

Lines changed: 543 additions & 2 deletions

File tree

cmd/review.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -425,6 +425,13 @@ Supports bulk operations:
425425
}
426426
}
427427

428+
// Auto-unblock dependents whose dependencies are now all closed
429+
if count, ids := database.CascadeUnblockDependents(issueID, sess.ID); count > 0 {
430+
for _, id := range ids {
431+
fmt.Printf(" ↓ Dependent %s auto-unblocked\n", id)
432+
}
433+
}
434+
428435
approved++
429436
}
430437

@@ -751,6 +758,13 @@ Examples:
751758
}
752759
}
753760

761+
// Auto-unblock dependents whose dependencies are now all closed
762+
if count, ids := database.CascadeUnblockDependents(issueID, sess.ID); count > 0 {
763+
for _, id := range ids {
764+
fmt.Printf(" ↓ Dependent %s auto-unblocked\n", id)
765+
}
766+
}
767+
754768
closed++
755769
}
756770

cmd/review_test.go

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1377,3 +1377,114 @@ func TestReviewWithWorkSessionTaggedIssue(t *testing.T) {
13771377
t.Error("Work session should NOT be ended by individual handoff")
13781378
}
13791379
}
1380+
1381+
// ============================================================================
1382+
// Auto-Unblock Integration Tests
1383+
// ============================================================================
1384+
1385+
func TestApproveAutoUnblocksDependents(t *testing.T) {
1386+
dir := t.TempDir()
1387+
database, err := db.Initialize(dir)
1388+
if err != nil {
1389+
t.Fatalf("Initialize failed: %v", err)
1390+
}
1391+
defer database.Close()
1392+
1393+
// Create blocker (in_review, ready to be approved)
1394+
blocker := &models.Issue{
1395+
Title: "Blocker",
1396+
Status: models.StatusInReview,
1397+
ImplementerSession: "ses_impl",
1398+
}
1399+
database.CreateIssue(blocker)
1400+
1401+
// Create dependent (blocked, depends on blocker)
1402+
dependent := &models.Issue{
1403+
Title: "Dependent",
1404+
Status: models.StatusBlocked,
1405+
}
1406+
database.CreateIssue(dependent)
1407+
database.AddDependency(dependent.ID, blocker.ID, "depends_on")
1408+
1409+
// Simulate approve: close the blocker then cascade unblock
1410+
blocker.Status = models.StatusClosed
1411+
database.UpdateIssue(blocker)
1412+
database.CascadeUnblockDependents(blocker.ID, "ses_reviewer")
1413+
1414+
// Verify dependent is now open
1415+
updated, _ := database.GetIssue(dependent.ID)
1416+
if updated.Status != models.StatusOpen {
1417+
t.Errorf("dependent should be open after blocker approved, got %s", updated.Status)
1418+
}
1419+
}
1420+
1421+
func TestCloseAutoUnblocksDependents(t *testing.T) {
1422+
dir := t.TempDir()
1423+
database, err := db.Initialize(dir)
1424+
if err != nil {
1425+
t.Fatalf("Initialize failed: %v", err)
1426+
}
1427+
defer database.Close()
1428+
1429+
blocker := &models.Issue{
1430+
Title: "Blocker",
1431+
Status: models.StatusOpen,
1432+
}
1433+
database.CreateIssue(blocker)
1434+
1435+
dependent := &models.Issue{
1436+
Title: "Dependent",
1437+
Status: models.StatusBlocked,
1438+
}
1439+
database.CreateIssue(dependent)
1440+
database.AddDependency(dependent.ID, blocker.ID, "depends_on")
1441+
1442+
// Simulate close: set closed then cascade unblock
1443+
blocker.Status = models.StatusClosed
1444+
database.UpdateIssue(blocker)
1445+
database.CascadeUnblockDependents(blocker.ID, "ses_closer")
1446+
1447+
updated, _ := database.GetIssue(dependent.ID)
1448+
if updated.Status != models.StatusOpen {
1449+
t.Errorf("dependent should be open after blocker closed, got %s", updated.Status)
1450+
}
1451+
}
1452+
1453+
func TestApproveAutoUnblockPartialDeps(t *testing.T) {
1454+
dir := t.TempDir()
1455+
database, err := db.Initialize(dir)
1456+
if err != nil {
1457+
t.Fatalf("Initialize failed: %v", err)
1458+
}
1459+
defer database.Close()
1460+
1461+
a1 := &models.Issue{
1462+
Title: "A1",
1463+
Status: models.StatusInReview,
1464+
ImplementerSession: "ses_impl",
1465+
}
1466+
a2 := &models.Issue{
1467+
Title: "A2",
1468+
Status: models.StatusOpen,
1469+
}
1470+
dependent := &models.Issue{
1471+
Title: "Dependent",
1472+
Status: models.StatusBlocked,
1473+
}
1474+
database.CreateIssue(a1)
1475+
database.CreateIssue(a2)
1476+
database.CreateIssue(dependent)
1477+
database.AddDependency(dependent.ID, a1.ID, "depends_on")
1478+
database.AddDependency(dependent.ID, a2.ID, "depends_on")
1479+
1480+
// Approve only A1
1481+
a1.Status = models.StatusClosed
1482+
database.UpdateIssue(a1)
1483+
database.CascadeUnblockDependents(a1.ID, "ses_reviewer")
1484+
1485+
// Dependent should still be blocked (A2 not closed)
1486+
updated, _ := database.GetIssue(dependent.ID)
1487+
if updated.Status != models.StatusBlocked {
1488+
t.Errorf("dependent should remain blocked (A2 still open), got %s", updated.Status)
1489+
}
1490+
}

internal/db/issue_relations.go

Lines changed: 80 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -232,6 +232,11 @@ func (db *DB) CascadeUpParentStatus(issueID string, targetStatus models.Status,
232232
cascadedIDs = append(cascadedIDs, parent.ID)
233233
cascadedCount++
234234

235+
// Auto-unblock issues that depend on this newly-closed parent
236+
if targetStatus == models.StatusClosed {
237+
db.CascadeUnblockDependents(parent.ID, sessionID)
238+
}
239+
235240
// Recursively check parent's parent
236241
moreCount, moreIDs := db.CascadeUpParentStatus(parent.ID, targetStatus, sessionID)
237242
cascadedCount += moreCount
@@ -240,6 +245,80 @@ func (db *DB) CascadeUpParentStatus(issueID string, targetStatus models.Status,
240245
return cascadedCount, cascadedIDs
241246
}
242247

248+
// CascadeUnblockDependents checks issues that depend on closedIssueID.
249+
// For each dependent in "blocked" status, if ALL its dependencies are now closed,
250+
// it transitions the dependent from blocked → open.
251+
// Returns the count and IDs of unblocked issues.
252+
func (db *DB) CascadeUnblockDependents(closedIssueID, sessionID string) (int, []string) {
253+
dependents, err := db.GetBlockedBy(closedIssueID)
254+
if err != nil || len(dependents) == 0 {
255+
return 0, nil
256+
}
257+
258+
var unblockedIDs []string
259+
260+
for _, depID := range dependents {
261+
issue, err := db.GetIssue(depID)
262+
if err != nil || issue == nil {
263+
continue
264+
}
265+
266+
if issue.Status != models.StatusBlocked {
267+
continue
268+
}
269+
270+
// Check if ALL dependencies of this issue are now closed
271+
deps, err := db.GetDependencies(depID)
272+
if err != nil {
273+
continue
274+
}
275+
276+
allClosed := true
277+
for _, d := range deps {
278+
depIssue, err := db.GetIssue(d)
279+
if err != nil || depIssue == nil {
280+
allClosed = false
281+
break
282+
}
283+
if depIssue.Status != models.StatusClosed {
284+
allClosed = false
285+
break
286+
}
287+
}
288+
289+
if !allClosed {
290+
continue
291+
}
292+
293+
prevData, _ := json.Marshal(issue)
294+
issue.Status = models.StatusOpen
295+
if err := db.UpdateIssue(issue); err != nil {
296+
continue
297+
}
298+
299+
newData, _ := json.Marshal(issue)
300+
db.LogAction(&models.ActionLog{
301+
SessionID: sessionID,
302+
ActionType: models.ActionUnblock,
303+
EntityType: "issue",
304+
EntityID: depID,
305+
PreviousData: string(prevData),
306+
NewData: string(newData),
307+
})
308+
309+
db.AddLog(&models.Log{
310+
IssueID: depID,
311+
SessionID: sessionID,
312+
Message: fmt.Sprintf("Auto-unblocked (dependency %s closed)", closedIssueID),
313+
Type: models.LogTypeProgress,
314+
})
315+
316+
unblockedIDs = append(unblockedIDs, depID)
317+
}
318+
319+
return len(unblockedIDs), unblockedIDs
320+
}
321+
243322
// ============================================================================
244323
// Dependency Functions
245324
// ============================================================================
@@ -287,7 +366,7 @@ func (db *DB) GetDependencies(issueID string) ([]string, error) {
287366
// GetBlockedBy returns what issues are blocked by this issue
288367
func (db *DB) GetBlockedBy(issueID string) ([]string, error) {
289368
rows, err := db.conn.Query(`
290-
SELECT issue_id FROM issue_dependencies WHERE depends_on_id = ?
369+
SELECT issue_id FROM issue_dependencies WHERE depends_on_id = ? AND relation_type = 'depends_on'
291370
`, issueID)
292371
if err != nil {
293372
return nil, err

0 commit comments

Comments
 (0)