Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion CLI.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
15 changes: 12 additions & 3 deletions docs/dependency-reconciliation.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand All @@ -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.
Expand All @@ -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).
51 changes: 51 additions & 0 deletions tests/cli/issue-management.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
});
});
86 changes: 86 additions & 0 deletions tests/database.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down