From 44cb18f1e25eaa16dd1261789e98c50d342a14c4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 10 Mar 2026 22:55:49 +0000 Subject: [PATCH 1/2] Initial plan From 96778e16269348b4cefbcc555c28db39d099bc37 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 10 Mar 2026 23:03:13 +0000 Subject: [PATCH 2/2] Add in_review unblocking tests and update docs Co-authored-by: SorraTheOrc <250240+SorraTheOrc@users.noreply.github.com> --- CLI.md | 3 +- docs/dependency-reconciliation.md | 15 ++++-- tests/cli/issue-management.test.ts | 51 ++++++++++++++++++ tests/database.test.ts | 86 ++++++++++++++++++++++++++++++ 4 files changed, 151 insertions(+), 4 deletions(-) diff --git a/CLI.md b/CLI.md index 3beb41a..6d298a6 100644 --- a/CLI.md +++ b/CLI.md @@ -183,7 +183,8 @@ Behavior: - `dep list --incoming` shows only inbound dependencies. **Automatic unblocking:** Dependents are automatically unblocked when all their blockers -become inactive (completed or deleted). This reconciliation happens via `db.update()` and +become inactive (completed, deleted, or moved to a non-blocking stage such as `in_review` +or `done`). This reconciliation happens via `db.update()` and `db.delete()` — any status or stage change triggers the reconciliation logic. See [Dependency Reconciliation](docs/dependency-reconciliation.md) for developer details. diff --git a/docs/dependency-reconciliation.md b/docs/dependency-reconciliation.md index 949360c..e1e49ad 100644 --- a/docs/dependency-reconciliation.md +++ b/docs/dependency-reconciliation.md @@ -15,7 +15,7 @@ All reconciliation logic lives in `src/database.ts`: | `reconcileDependentsForTarget(targetId)` | ~1811 | Entry point: finds all dependents of `targetId` and reconciles each one | | `reconcileDependentStatus(dependentId)` | ~1772 | Determines whether a dependent should be blocked or unblocked | | `reconcileBlockedStatus(itemId)` | ~1749 | Sets or clears `blocked` status based on active blockers | -| `isDependencyActive(item)` | ~1701 | Returns `true` if an item is an active blocker (not completed, not deleted) | +| `isDependencyActive(item)` | ~1701 | Returns `true` if an item is an active blocker (not completed, not deleted, not in `in_review` or `done` stage) | | `hasActiveBlockers(itemId)` | ~1738 | Returns `true` if any inbound dependency edges point to active items | | `getInboundDependents(targetId)` | ~1726 | Returns IDs of items that depend on `targetId` | | `listDependencyEdgesTo(targetId)` | ~1696 | Returns all dependency edges where `targetId` is the prerequisite | @@ -39,10 +39,19 @@ All reconciliation logic lives in `src/database.ts`: | Close a blocker (sole blocker) | Dependent unblocked (status -> `open`) | | Close a blocker (other blockers remain) | Dependent stays `blocked` | | Delete a blocker | Dependent unblocked if no other active blockers | +| Move blocker to `in_review` stage (sole blocker) | Dependent unblocked (status -> `open`) | +| Move blocker to `in_review` stage (other active blockers remain) | Dependent stays `blocked` | +| Move blocker to `done` stage | Dependent unblocked if no other active blockers | | Reopen a closed blocker | Dependent re-blocked (status -> `blocked`) | +| Move blocker back from `in_review` to an active stage | Dependent re-blocked (status -> `blocked`) | | Close already-closed blocker | No-op (idempotent) | +| Move blocker to `in_review` multiple times | No-op (idempotent) | | Dependent is completed/deleted | No status change (already terminal) | +> **Note:** The `in_review` stage is treated as non-blocking for **dependency edges only**. +> Parent/child relationships are not affected by this change — a child item moving to +> `in_review` does not unblock its parent. + ## CLI and TUI Parity Both the CLI `close` command (`src/commands/close.ts`) and the TUI close handler (`src/tui/controller.ts`) call `db.update(id, { status: 'completed' })`, which triggers the same reconciliation path. There is no separate unblock logic in either interface — all unblocking is handled by the shared database layer. @@ -53,5 +62,5 @@ The `wl dep add` command (`src/commands/dep.ts`) adds a dependency edge and then ## Test Coverage -- **Unit tests**: `tests/database.test.ts` — `dependency edges` describe block contains tests for single-blocker unblock, multi-blocker scenarios, chain dependencies, delete unblock, reopen re-block, idempotence, and more. -- **CLI integration tests**: `tests/cli/issue-management.test.ts` — tests for `close` and `dep` commands verifying end-to-end unblock behaviour through the CLI. +- **Unit tests**: `tests/database.test.ts` — `dependency edges` describe block contains tests for single-blocker unblock, multi-blocker scenarios, chain dependencies, delete unblock, reopen re-block, idempotence, `in_review` stage unblocking (single blocker, partial multi-blocker, all blockers, mixed in_review/completed, idempotence, re-block on stage revert, multiple dependents), and more. +- **CLI integration tests**: `tests/cli/issue-management.test.ts` — tests for `close` and `dep` commands verifying end-to-end unblock behaviour through the CLI, including `in_review` stage unblocking (single blocker → in_review, partial multi-blocker, all blockers → in_review). diff --git a/tests/cli/issue-management.test.ts b/tests/cli/issue-management.test.ts index 7c8c8ed..bddeccd 100644 --- a/tests/cli/issue-management.test.ts +++ b/tests/cli/issue-management.test.ts @@ -686,5 +686,56 @@ describe('CLI Issue Management Tests', () => { expect(result.error).toBe('Cannot use --incoming and --outgoing together.'); } }); + + it('should unblock dependent when sole blocker moves to in_review stage via update', async () => { + const { stdout: blockedStdout } = await execAsync(`tsx ${cliPath} --json create -t "Blocked"`); + const { stdout: blockerStdout } = await execAsync(`tsx ${cliPath} --json create -t "Blocker"`); + const blockedId = JSON.parse(blockedStdout).workItem.id; + const blockerId = JSON.parse(blockerStdout).workItem.id; + + await execAsync(`tsx ${cliPath} --json dep add ${blockedId} ${blockerId}`); + const { stdout: blockedShowStdout } = await execAsync(`tsx ${cliPath} --json show ${blockedId}`); + expect(JSON.parse(blockedShowStdout).workItem.status).toBe('blocked'); + + await execAsync(`tsx ${cliPath} --json update ${blockerId} --status completed --stage in_review`); + const { stdout: unblockedShowStdout } = await execAsync(`tsx ${cliPath} --json show ${blockedId}`); + expect(JSON.parse(unblockedShowStdout).workItem.status).toBe('open'); + }); + + it('should keep dependent blocked when only one of multiple blockers moves to in_review', async () => { + const { stdout: blockedStdout } = await execAsync(`tsx ${cliPath} --json create -t "Blocked"`); + const { stdout: blockerAStdout } = await execAsync(`tsx ${cliPath} --json create -t "BlockerA"`); + const { stdout: blockerBStdout } = await execAsync(`tsx ${cliPath} --json create -t "BlockerB"`); + const blockedId = JSON.parse(blockedStdout).workItem.id; + const blockerAId = JSON.parse(blockerAStdout).workItem.id; + const blockerBId = JSON.parse(blockerBStdout).workItem.id; + + await execAsync(`tsx ${cliPath} --json dep add ${blockedId} ${blockerAId}`); + await execAsync(`tsx ${cliPath} --json dep add ${blockedId} ${blockerBId}`); + + await execAsync(`tsx ${cliPath} --json update ${blockerAId} --status completed --stage in_review`); + const { stdout: stillBlockedStdout } = await execAsync(`tsx ${cliPath} --json show ${blockedId}`); + expect(JSON.parse(stillBlockedStdout).workItem.status).toBe('blocked'); + }); + + it('should unblock dependent when all blockers move to in_review', async () => { + const { stdout: blockedStdout } = await execAsync(`tsx ${cliPath} --json create -t "Blocked"`); + const { stdout: blockerAStdout } = await execAsync(`tsx ${cliPath} --json create -t "BlockerA"`); + const { stdout: blockerBStdout } = await execAsync(`tsx ${cliPath} --json create -t "BlockerB"`); + const blockedId = JSON.parse(blockedStdout).workItem.id; + const blockerAId = JSON.parse(blockerAStdout).workItem.id; + const blockerBId = JSON.parse(blockerBStdout).workItem.id; + + await execAsync(`tsx ${cliPath} --json dep add ${blockedId} ${blockerAId}`); + await execAsync(`tsx ${cliPath} --json dep add ${blockedId} ${blockerBId}`); + + await execAsync(`tsx ${cliPath} --json update ${blockerAId} --status completed --stage in_review`); + const { stdout: stillBlockedStdout } = await execAsync(`tsx ${cliPath} --json show ${blockedId}`); + expect(JSON.parse(stillBlockedStdout).workItem.status).toBe('blocked'); + + await execAsync(`tsx ${cliPath} --json update ${blockerBId} --status completed --stage in_review`); + const { stdout: unblockedStdout } = await execAsync(`tsx ${cliPath} --json show ${blockedId}`); + expect(JSON.parse(unblockedStdout).workItem.status).toBe('open'); + }); }); }); diff --git a/tests/database.test.ts b/tests/database.test.ts index 43d17b7..9796412 100644 --- a/tests/database.test.ts +++ b/tests/database.test.ts @@ -657,6 +657,92 @@ describe('WorklogDatabase', () => { } } }); + + describe('in_review stage unblocking (dependency edges only)', () => { + it('should unblock dependent when sole blocker moves to in_review stage', () => { + const blocker = db.create({ title: 'Blocker', status: 'open', stage: 'in_progress' }); + const blocked = db.create({ title: 'Blocked', status: 'blocked' }); + db.addDependencyEdge(blocked.id, blocker.id); + + db.update(blocker.id, { stage: 'in_review' }); + expect(db.get(blocked.id)?.status).toBe('open'); + }); + + it('should keep dependent blocked when one of multiple blockers moves to in_review', () => { + const blockerA = db.create({ title: 'Blocker A', status: 'open', stage: 'in_progress' }); + const blockerB = db.create({ title: 'Blocker B', status: 'open', stage: 'in_progress' }); + const blocked = db.create({ title: 'Blocked', status: 'blocked' }); + db.addDependencyEdge(blocked.id, blockerA.id); + db.addDependencyEdge(blocked.id, blockerB.id); + + db.update(blockerA.id, { stage: 'in_review' }); + expect(db.get(blocked.id)?.status).toBe('blocked'); + }); + + it('should unblock dependent when all blockers move to in_review', () => { + const blockerA = db.create({ title: 'Blocker A', status: 'open', stage: 'in_progress' }); + const blockerB = db.create({ title: 'Blocker B', status: 'open', stage: 'in_progress' }); + const blocked = db.create({ title: 'Blocked', status: 'blocked' }); + db.addDependencyEdge(blocked.id, blockerA.id); + db.addDependencyEdge(blocked.id, blockerB.id); + + db.update(blockerA.id, { stage: 'in_review' }); + expect(db.get(blocked.id)?.status).toBe('blocked'); + + db.update(blockerB.id, { stage: 'in_review' }); + expect(db.get(blocked.id)?.status).toBe('open'); + }); + + it('should unblock dependent when mix of in_review and completed blockers are all non-blocking', () => { + const blockerA = db.create({ title: 'Blocker A', status: 'open', stage: 'in_progress' }); + const blockerB = db.create({ title: 'Blocker B', status: 'open', stage: 'in_progress' }); + const blocked = db.create({ title: 'Blocked', status: 'blocked' }); + db.addDependencyEdge(blocked.id, blockerA.id); + db.addDependencyEdge(blocked.id, blockerB.id); + + db.update(blockerA.id, { status: 'completed' }); + expect(db.get(blocked.id)?.status).toBe('blocked'); + + db.update(blockerB.id, { stage: 'in_review' }); + expect(db.get(blocked.id)?.status).toBe('open'); + }); + + it('should be idempotent: moving blocker to in_review multiple times does not break state', () => { + const blocker = db.create({ title: 'Blocker', status: 'open', stage: 'in_progress' }); + const blocked = db.create({ title: 'Blocked', status: 'blocked' }); + db.addDependencyEdge(blocked.id, blocker.id); + + db.update(blocker.id, { stage: 'in_review' }); + expect(db.get(blocked.id)?.status).toBe('open'); + + db.update(blocker.id, { stage: 'in_review' }); + expect(db.get(blocked.id)?.status).toBe('open'); + }); + + it('should re-block dependent when blocker moves back from in_review to in_progress', () => { + const blocker = db.create({ title: 'Blocker', status: 'open', stage: 'in_progress' }); + const blocked = db.create({ title: 'Blocked', status: 'blocked' }); + db.addDependencyEdge(blocked.id, blocker.id); + + db.update(blocker.id, { stage: 'in_review' }); + expect(db.get(blocked.id)?.status).toBe('open'); + + db.update(blocker.id, { stage: 'in_progress' }); + expect(db.get(blocked.id)?.status).toBe('blocked'); + }); + + it('should unblock multiple dependents when their shared blocker moves to in_review', () => { + const blocker = db.create({ title: 'Shared Blocker', status: 'open', stage: 'in_progress' }); + const dependentA = db.create({ title: 'Dependent A', status: 'blocked' }); + const dependentB = db.create({ title: 'Dependent B', status: 'blocked' }); + db.addDependencyEdge(dependentA.id, blocker.id); + db.addDependencyEdge(dependentB.id, blocker.id); + + db.update(blocker.id, { stage: 'in_review' }); + expect(db.get(dependentA.id)?.status).toBe('open'); + expect(db.get(dependentB.id)?.status).toBe('open'); + }); + }); }); describe('import and export', () => {