Files
pad/internal
xarmian 5320f988ee feat(mcp): expand HTTPHandlerDispatcher route table — 10 new commands + routeSpec framework (TASK-966) (#344)
* feat(mcp): expand HTTPHandlerDispatcher route table — 10 new commands + declarative routeSpec framework (TASK-966)

TASK-965 shipped HTTPHandlerDispatcher with `item create` as the
proof-of-concept route. This expansion lays a small declarative
framework (`routeSpec` → `RouteMapper`) and wires the high-value
read + write surface, so an OAuth-authenticated agent connecting via
the future /mcp endpoint (TASK-950) gets a useful tool surface
out-of-the-box rather than 11/12 tools returning "not yet implemented
over HTTP transport."

## Framework

`routeSpec` (in dispatch_http_routes.go) is the declarative shape
shared across simple commands:

  routeSpec{
      method:       http.MethodGet,
      pathTemplate: "/api/v1/workspaces/{workspace}/items/{ref}",
      queryParams:  map[string]string{"q": "query", ...},  // dst→src
      bodyKeys:     []string{"title", "content", ...},
  }

`{placeholder}` segments substitute from the input map (snake_case
keys per TASK-964); `collection` and `target_collection` placeholders
are auto-normalized via `collections.NormalizeSlug`. queryParams maps
URL-query names to input keys and handles type coercion for ints
(json.Number / float64) and bool presence-only treatment. bodyKeys
emits a flat JSON body with empty values omitted.

Commands that don't fit the shape (item.create's fields-rolling,
item.move's nested overrides, item.list's path-varies-on-arg,
item.search's renamed q param, item.comment's message→body rename)
stay as standalone RouteMapper functions. The escape hatch is
deliberate — the simple cases get one-line entries; the weird cases
get full functions with their own test coverage.

## Commands wired (10 new + item.create)

| Cmd | Method | Path / Notes |
| --- | --- | --- |
| item create *(prior)* | POST | /workspaces/{ws}/collections/{coll}/items, fields-rolling |
| item show | GET | /workspaces/{ws}/items/{ref} |
| item delete | DELETE | /workspaces/{ws}/items/{ref} |
| item list | GET | path varies on collection arg; filters → query |
| item move | POST | /items/{ref}/move with target_collection + field_overrides body |
| item search | GET | /search?q=...&workspace=... (cross-workspace) |
| item comment | POST | /items/{ref}/comments, message→body, reply_to→parent_id |
| item comments | GET | /items/{ref}/comments |
| project dashboard | GET | /workspaces/{ws}/dashboard |
| collection list | GET | /workspaces/{ws}/collections |
| role list | GET | /workspaces/{ws}/agent-roles |

## Out of scope

`item update` requires read-modify-write semantics (the CLI fetches
the existing fields JSON, merges in --status / --priority / --field
entries, then PATCHes the merged result; the handler treats Fields
as a complete replacement). Implementing that here would mean making
two HTTP calls per dispatch and adding a new "prefetch" hook to the
framework — out of scope for this PR. Captured as the next follow-up.

`project next` / `project standup` / `project changelog` are CLI-side
compositions (multiple API calls + presentation logic) with no
single backing endpoint. Their HTTP equivalent for an agent is "call
project dashboard and read the suggested_next field." Documented in
the follow-up task.

The remaining ~40 commands (attachments, webhooks, library, github,
workspace audit-log, role create / delete, ...) are tracked in the
follow-up.

## Tests

- Framework unit tests: expandPath (substitution, normalization,
  escaping, error paths), buildQuery (rename, type coercion,
  json.Number support, empty-skip), flatJSONBody (omission rules).
- Per-command unit tests: every wired command has a happy-path
  assertion + at least one error path. Custom mappers
  (item.list / move / search / comment) get table-driven coverage of
  their renames + path-variation behaviour.
- Lock test: TestRouteTable_ContainsExpectedCommands fails loudly if
  an entry gets accidentally deleted.
- Integration smoke (TestHTTPHandlerDispatcher_Integration_ReadPaths)
  drives item create → list → show → project dashboard → collection
  list end-to-end against a real *server.Server, asserting the
  full chain stays wired together after the refactor.

Parent: PLAN-943.

* fix(mcp): item.list parity with CLI per Codex review (round 1)

Codex caught three CLI-parity bugs in mapItemList:

1. Default active-status filter missing. `pad item list` ships a
   broad inclusion list of active statuses unless --status or --all
   is set; the HTTP mapper returned no status filter, so MCP would
   leak done/completed/archived items by default.

2. `--parent <ref>` mapped to query param `parent_id`, which the
   server treats as a literal ID. The CLI uses `parent`, which
   parseItemListParams' unknown-key path routes to resolveParentFilter
   for ref → UUID resolution. Without this, `?parent=PLAN-3` would
   silently match nothing.

3. `--assign <name>` passed straight through as `assigned_user_id`.
   The CLI resolves names → user IDs via a workspace-members lookup
   first; passing the raw name to the store filter (which compares
   against `i.assigned_user_id` UUID) returns nothing.

Fixes:

1. Added `defaultActiveStatusFilter` constant mirroring the CLI's
   hardcoded list at cmd/pad/main.go itemListCmd. Applied when
   neither --status nor --all is set; --all drops it (so done items
   show); explicit --status wins (so the user can pin to any tier).

2. Renamed the query-param target from `parent_id` to `parent` so
   the handler's resolveParentFilter sees it as a field filter and
   does ref→UUID resolution.

3. Reject `--assign` with a clear error pointing agents at
   `--field assigned_user_id=<uuid>` for explicit-ID filtering. Same
   pattern as the existing rejection on item.create. Full name → ID
   prefetch belongs in the same follow-up that handles `--assign` on
   item create / update.

Tests:
- TestRoute_ItemList_AllItemsPath_AppliesDefaultActiveStatusFilter
  asserts the broad inclusion list is on the wire and verifies a
  spot-check of well-known active + terminal statuses.
- TestRoute_ItemList_AllFlagDropsDefaultStatus pins --all behaviour.
- TestRoute_ItemList_ExplicitStatusOverridesDefault pins explicit
  --status precedence.
- TestRoute_ItemList_FiltersAsQuery now asserts `parent` (not
  `parent_id`) is what reaches the wire.
- TestRoute_ItemList_RejectsAssignByName covers the rejection.
- TestRoute_ItemList_NumericLimitFromJSONNumber covers the
  json.Number path through the new numericInput helper.

Parent: PLAN-943.

* fix(mcp): normalize collection alias on item.search per Codex review (round 2)

Codex caught: `pad item search foo --collection task` was passing
"task" through verbatim to /api/v1/search?collection=task, but the
search store filters via `c.slug = ?` (exact match) and 0-matches
shorthand. The CLI normalizes to "tasks" first; mapper now does the
same.

Lifted the mutation into a tiny cloneStringMap helper so the input
map the caller hands us isn't accidentally rewritten — the registry
attaches the original via WithDispatchInput, and downstream code
shouldn't see a mapper's normalization leak back.

Tests:
- TestRoute_ItemSearch_NormalizesCollectionAlias asserts task → tasks
  on the wire.
- TestRoute_ItemSearch_DoesNotMutateInput pins the no-mutation
  contract so future refactors of the helper don't regress.

Parent: PLAN-943.
2026-05-01 12:25:46 -04:00
..
2026-03-26 01:52:36 +00:00