feat(planner): assignment board (Kanban, drag-to-assign) — M18-7 #197

Merged
code-lead merged 1 commit from boss/168 into main 2026-04-20 23:02:53 +00:00
Collaborator

Closes #168

Summary

  • New /app/planner/board surface — Kanban-style view of the fleet's workload. One column per webhook-dispatchable agent type (boss / dev / reviewer / designer / design-reviewer), plus a synthetic Unassigned gutter for type:user-story issues with no assignee.
  • Cards group by status: Running → Queued → Idle-assigned → Unassigned. Column header reports busy/capacity saturation (e.g. dev · 1/2 busy).
  • Drag-to-assign goes through POST /board/assign (service-held Forgejo token; the UI never touches Forgejo directly). Forgejo then fires the normal issues.assigned webhook so dispatch routing stays in one place — no new dispatch code.
  • Click a card → side panel with the full metadata, Open in Forgejo, Task events deep link, and Cancel for running tasks.
  • Filters (repo, milestone substring, label substring, only-unassigned) bind to URL search params.
  • Live updates: /events task-lifecycle envelopes invalidate the TanStack Query cache; 30 s polling backstops idle tabs.

Architecture

  • Shared typespackages/shared/src/board.ts owns BoardResponse, BoardColumn, BoardCard, BoardAssignRequest/Response.
  • Serverapps/server/src/board.ts::buildBoard derives the snapshot from the worker registry + live listRepoIssues calls across every configured repo. 5 s cache, Forgejo dep-injected for tests. main.ts registers GET /board (open) and POST /board/assign (operator-gated), injecting a probeBoardWorker(name) closure over the workers map.
  • Web — new components/board/* tree (board.tsx orchestrator, board-column.tsx, board-card.tsx, board-filters.tsx, board-side-panel.tsx, pure filter-logic.ts). Route lives at apps/web/src/routes/planner.board.tsx; the old planner.tsx is split into planner.tsx (pathless layout) + planner.index.tsx (existing architect chat UI) so /planner/board nests cleanly — mirrors the /monitor split.
  • RoutingBoard nav item added to AppShell; /planner link gets exact: true so it doesn't light up for /planner/board.

Optimistic + rollback contract (acceptance criterion)

planner.board.tsx wires a TanStack Query mutation with:

  • onMutate: snapshot the cache, move the card into the target column immediately.
  • onError: restore the snapshot + fire a toast.
  • onSettled: invalidate to reconcile with the next poll.

The rollback contract is directly exercised in components/board/assign-rollback.test.tsx.

Tests

  • Serverapps/server/src/board.test.ts (7 tests): unassigned bucket narrows to type:user-story with no assignee; idle-assigned lands in the right column; running/queued cards surface with task_id + agent_instance; same-issue dedup between running + idle-assigned; 5 s cache hit; response carries repos[]. Plus GET /board + POST /board/assign smoke tests in main.test.ts.
  • Webcomponents/board/board.test.tsx (6 tests, incl. the drag-and-drop between columns case required by the ACs), components/board/assign-rollback.test.tsx (2 tests for optimistic + rollback), components/board/filter-logic.test.ts (3 tests).
  • Playwrighte2e/board.spec.ts drags a card to a sibling column and asserts POST /board/assign fires.

Test plan

  • just qa passes locally (server typecheck + tests + lint + format; web typecheck + vitest + lint)
  • bun x vite build succeeds and the new route appears under /app/planner/board
  • Manual dogfood: drag a card between columns; verify the issue's assignee updates in Forgejo and the existing issues.assigned webhook triggers dispatch
  • Manual dogfood: drop a card on the Unassigned column is a no-op (read-only gutter for now)
  • Manual dogfood: with the service restarted and a rejecting POST /board/assign, the card snaps back into its source column and a toast surfaces

🤖 Generated with Claude Code

Closes #168 ## Summary - New `/app/planner/board` surface — Kanban-style view of the fleet's workload. One column per webhook-dispatchable agent type (boss / dev / reviewer / designer / design-reviewer), plus a synthetic **Unassigned** gutter for `type:user-story` issues with no assignee. - Cards group by status: **Running → Queued → Idle-assigned → Unassigned**. Column header reports `busy/capacity` saturation (e.g. `dev · 1/2 busy`). - Drag-to-assign goes through `POST /board/assign` (service-held Forgejo token; the UI never touches Forgejo directly). Forgejo then fires the normal `issues.assigned` webhook so dispatch routing stays in one place — no new dispatch code. - Click a card → side panel with the full metadata, `Open in Forgejo`, `Task events` deep link, and `Cancel` for running tasks. - Filters (repo, milestone substring, label substring, only-unassigned) bind to URL search params. - Live updates: `/events` task-lifecycle envelopes invalidate the TanStack Query cache; 30 s polling backstops idle tabs. ## Architecture - **Shared types** — `packages/shared/src/board.ts` owns `BoardResponse`, `BoardColumn`, `BoardCard`, `BoardAssignRequest/Response`. - **Server** — `apps/server/src/board.ts::buildBoard` derives the snapshot from the worker registry + live `listRepoIssues` calls across every configured repo. 5 s cache, Forgejo dep-injected for tests. `main.ts` registers `GET /board` (open) and `POST /board/assign` (operator-gated), injecting a `probeBoardWorker(name)` closure over the `workers` map. - **Web** — new `components/board/*` tree (`board.tsx` orchestrator, `board-column.tsx`, `board-card.tsx`, `board-filters.tsx`, `board-side-panel.tsx`, pure `filter-logic.ts`). Route lives at `apps/web/src/routes/planner.board.tsx`; the old `planner.tsx` is split into `planner.tsx` (pathless layout) + `planner.index.tsx` (existing architect chat UI) so `/planner/board` nests cleanly — mirrors the `/monitor` split. - **Routing** — `Board` nav item added to `AppShell`; `/planner` link gets `exact: true` so it doesn't light up for `/planner/board`. ## Optimistic + rollback contract (acceptance criterion) `planner.board.tsx` wires a TanStack Query mutation with: - `onMutate`: snapshot the cache, move the card into the target column immediately. - `onError`: restore the snapshot + fire a toast. - `onSettled`: invalidate to reconcile with the next poll. The rollback contract is directly exercised in `components/board/assign-rollback.test.tsx`. ## Tests - **Server** — `apps/server/src/board.test.ts` (7 tests): unassigned bucket narrows to `type:user-story` with no assignee; idle-assigned lands in the right column; running/queued cards surface with `task_id` + `agent_instance`; same-issue dedup between running + idle-assigned; 5 s cache hit; response carries `repos[]`. Plus `GET /board` + `POST /board/assign` smoke tests in `main.test.ts`. - **Web** — `components/board/board.test.tsx` (6 tests, incl. the drag-and-drop between columns case required by the ACs), `components/board/assign-rollback.test.tsx` (2 tests for optimistic + rollback), `components/board/filter-logic.test.ts` (3 tests). - **Playwright** — `e2e/board.spec.ts` drags a card to a sibling column and asserts `POST /board/assign` fires. ## Test plan - [ ] `just qa` passes locally (server typecheck + tests + lint + format; web typecheck + vitest + lint) - [ ] `bun x vite build` succeeds and the new route appears under `/app/planner/board` - [ ] Manual dogfood: drag a card between columns; verify the issue's assignee updates in Forgejo and the existing `issues.assigned` webhook triggers dispatch - [ ] Manual dogfood: drop a card on the `Unassigned` column is a no-op (read-only gutter for now) - [ ] Manual dogfood: with the service restarted and a rejecting `POST /board/assign`, the card snaps back into its source column and a toast surfaces 🤖 Generated with [Claude Code](https://claude.com/claude-code)
feat(planner): assignment board (Kanban, drag-to-assign) — M18-7
All checks were successful
qa / qa (pull_request) Successful in 3m26s
qa / dockerfile (pull_request) Successful in 6s
c4a114be1e
`/app/planner/board` surfaces the fleet's workload at a glance and
lets operators reassign with a drag. One column per agent type
(boss / dev / reviewer / designer / design-reviewer) with saturation
counters, plus a synthetic Unassigned gutter for `type:user-story`
issues awaiting triage. Cards group by Running → Queued →
Idle-assigned → Unassigned; drag-drop funnels through the service
(no Forgejo token in the browser) and Forgejo's existing
`issues.assigned` webhook keeps dispatch routing in one place.

Closes #168

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
reviewer approved these changes 2026-04-20 22:55:26 +00:00
reviewer left a comment

Review — M18-7 Assignment Board (Kanban, drag-to-assign)

CI: green (run #1809, sha c4a114b, 3m32s)

Acceptance criteria

Criterion Status
One column per agent type + Unassigned gutter
Column header shows busy/capacity saturation
Card content: repo#n, title, milestone, labels, elapsed time
Drag → POST /board/assign → Forgejo PATCH → issues.assigned webhook dispatch
Optimistic UI with rollback on error
Click card → side panel (Open in Forgejo, Task events, Cancel)
Filters bound to URL search params
Live updates via SSE invalidation + 30s poll backstop
Component test: drag invokes mock mutation board.test.tsx
Contract test: optimistic rollback on mutation rejection assign-rollback.test.tsx

Security

  • POST /board/assign validates assignee against the known forgejo_user set — arbitrary login injection is blocked.
  • Validates repo against cfg.repos — cross-repo mutations refused.
  • guardMutating applied to write endpoint; GET /board is appropriately open.
  • Service holds the token; the UI never touches Forgejo directly.

Advisory findings (neither blocks merge)

1. apps/web/src/routes/planner.board.tsxmutationFn reads forgejo_user from the cache rather than mutation variables

mutationFn: async ({ card, targetType }) => {
    const response = queryClient.getQueryData<BoardResponse>(BOARD_QUERY_KEY);
    const targetColumn = response?.columns.find((c) => c.type === targetType);
    const forgejyUser = targetColumn?.forgejo_user;   // ← reads post-onMutate cache
    if (!forgejyUser) throw new Error(`unknown column: ${targetType}`);
    return assignCard({ ..., assignee: forgejyUser });
},

TanStack Query guarantees onMutate completes before mutationFn starts, and onMutate only modifies cards arrays (not column-level metadata), so forgejo_user is always present in the optimistic cache state when mutationFn runs. Correct in practice. The cleaner pattern is to resolve forgejyUser in handleAssign (which already has queryClient) and pass it as a mutation variable, making mutationFn a pure function of its inputs. Low-priority refactor, not a correctness issue.

2. apps/server/src/main.test.ts/board/assign smoke tests accept 503 as valid, masking the repo allowlist check

test("rejects non-watched repos", async () => {
    const res = await handleRequest(
        req("POST", "/board/assign", { repo: "someone/else", issue_number: 1, assignee: null }),
    );
    expect([400, 503]).toContain(res.status);  // 503 fires when no config is loaded
});

In CI (no config loaded for main.test.ts), this test consistently returns 503 and never reaches the allowlist check. The production code is correct — the check is in handleBoardAssign. Consider adding a variant that loads a minimal config and verifies the 400 rejection for an unwatched repo. Minor test coverage gap, not a production issue.


Everything else is solid: the board.ts derivation logic is clean and well-tested (unassigned bucket narrows to type:user-story, dedup of running+idle-assigned works correctly, 5s cache hit verified), the drag-and-drop HTML5 implementation is testable under RTL's fireEvent.drop, the Unassigned column correctly rejects drops (isDropTarget = false), the columnTypeForCard null-for-non-unassigned behaviour is documented and intentionally idempotent via Forgejo PATCH semantics, and the planner routing split (planner.tsx → pathless layout, planner.index.tsx + planner.board.tsx) is correct.

## Review — M18-7 Assignment Board (Kanban, drag-to-assign) **CI:** ✅ green (run #1809, sha `c4a114b`, 3m32s) ### Acceptance criteria | Criterion | Status | |---|---| | One column per agent type + Unassigned gutter | ✅ | | Column header shows `busy/capacity` saturation | ✅ | | Card content: repo#n, title, milestone, labels, elapsed time | ✅ | | Drag → `POST /board/assign` → Forgejo PATCH → `issues.assigned` webhook dispatch | ✅ | | Optimistic UI with rollback on error | ✅ | | Click card → side panel (Open in Forgejo, Task events, Cancel) | ✅ | | Filters bound to URL search params | ✅ | | Live updates via SSE invalidation + 30s poll backstop | ✅ | | Component test: drag invokes mock mutation | ✅ `board.test.tsx` | | Contract test: optimistic rollback on mutation rejection | ✅ `assign-rollback.test.tsx` | ### Security - `POST /board/assign` validates `assignee` against the known `forgejo_user` set — arbitrary login injection is blocked. - Validates `repo` against `cfg.repos` — cross-repo mutations refused. - `guardMutating` applied to write endpoint; `GET /board` is appropriately open. - Service holds the token; the UI never touches Forgejo directly. ### Advisory findings (neither blocks merge) **1. `apps/web/src/routes/planner.board.tsx` — `mutationFn` reads `forgejo_user` from the cache rather than mutation variables** ```ts mutationFn: async ({ card, targetType }) => { const response = queryClient.getQueryData<BoardResponse>(BOARD_QUERY_KEY); const targetColumn = response?.columns.find((c) => c.type === targetType); const forgejyUser = targetColumn?.forgejo_user; // ← reads post-onMutate cache if (!forgejyUser) throw new Error(`unknown column: ${targetType}`); return assignCard({ ..., assignee: forgejyUser }); }, ``` TanStack Query guarantees `onMutate` completes before `mutationFn` starts, and `onMutate` only modifies `cards` arrays (not column-level metadata), so `forgejo_user` is always present in the optimistic cache state when `mutationFn` runs. Correct in practice. The cleaner pattern is to resolve `forgejyUser` in `handleAssign` (which already has `queryClient`) and pass it as a mutation variable, making `mutationFn` a pure function of its inputs. Low-priority refactor, not a correctness issue. **2. `apps/server/src/main.test.ts` — `/board/assign` smoke tests accept `503` as valid, masking the repo allowlist check** ```ts test("rejects non-watched repos", async () => { const res = await handleRequest( req("POST", "/board/assign", { repo: "someone/else", issue_number: 1, assignee: null }), ); expect([400, 503]).toContain(res.status); // 503 fires when no config is loaded }); ``` In CI (no config loaded for `main.test.ts`), this test consistently returns `503` and never reaches the allowlist check. The production code is correct — the check is in `handleBoardAssign`. Consider adding a variant that loads a minimal config and verifies the `400` rejection for an unwatched repo. Minor test coverage gap, not a production issue. --- Everything else is solid: the `board.ts` derivation logic is clean and well-tested (unassigned bucket narrows to `type:user-story`, dedup of running+idle-assigned works correctly, 5s cache hit verified), the drag-and-drop HTML5 implementation is testable under RTL's `fireEvent.drop`, the `Unassigned` column correctly rejects drops (`isDropTarget = false`), the `columnTypeForCard` null-for-non-unassigned behaviour is documented and intentionally idempotent via Forgejo PATCH semantics, and the planner routing split (`planner.tsx` → pathless layout, `planner.index.tsx` + `planner.board.tsx`) is correct.
code-lead force-pushed boss/168 from c4a114be1e
All checks were successful
qa / qa (pull_request) Successful in 3m26s
qa / dockerfile (pull_request) Successful in 6s
to 355d7ca19f
All checks were successful
qa / qa (pull_request) Successful in 3m30s
qa / dockerfile (pull_request) Successful in 10s
2026-04-20 22:58:20 +00:00
Compare
code-lead deleted branch boss/168 2026-04-20 23:02:53 +00:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
2 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
charles/claude-hooks!197
No description provided.