Skip to content

moooon

Motir

Vibe your whole project. Bring an idea — Motir's three AI layers plan it, track it, and ship it, end to end. You're looking at Motir, built in Motir.

  • Vibe Project
  • Open Source
  • AI Agent
  • AI Loop
1
requests
0
upvotes
145
planned
1,361
shipped

Motir · Work items

MOTIR-2042Done

11.2.4 `GET /api/v1/projects/{projectKey}/work-items` — the FilterAST-narrowed, cursor-paged collection

The flagship read: the endpoint an integration actually starts from. app/api/v1/projects/[projectKey]/work-items/route.ts, GET, scope: 'read', composing withV1Route — a thin adapter over the keyset read 11.2.3 ships, returning the summary shape 11.2.2 pins.

What to build

Query narrowing — ?filter=, in the versioned FilterAST, decoded by the SAME codec the web app uses. decodeFilterParam (lib/filters/ast.ts) parses the v1:<base64url> param the /items URL already carries, and search_work_items rides the same AST through its own carrier. ONE query grammar, never a parallel one — no ad-hoc ?status=&assignee= axes bolted onto v1, because a second grammar is a second thing to keep in sync with the registry and the first place the API and the product start disagreeing about what a filter means. A decode failure ({ ok: false, reason }) is a 422 carrying a stable code; a FilterValidationError raised deeper (unknown field / operator / bad value / too large) is likewise 422, each code added to DOMAIN_ERROR_STATUS deliberately.

Paging — ?cursor=&limit=. parsePageRequest validates both (default 50, clamped at 100, a bad cursor or limit → 422 before any read). Feed its cursor into the service's after, then encode the next cursor with encodePageCursor from the LAST returned row's { createdAt, id }, or null when no more remain.

⚠️ Do NOT use paginateKeyset here. It sorts and slices a fully-read array — correct for GET /api/v1/workspaces (a user's own memberships), wrong for a collection of 1800+ rows, and the reason 11.2.3 exists. The route composes parsePageRequest + encodePageCursor and lets the database do the windowing.

The response is the shared list envelope — { items, nextCursor } — with each row through presentWorkItemSummary. Same envelope as every other v1 collection; nothing endpoint-specific about its shape.

Project resolution is projectsService.getByKey(projectKey, ctx); a key outside the token's workspace is a 404, indistinguishable from a key that never existed.

Scope BOUNDARY

Ends at this one GET. It adds no filter axis (the registry's field set is what it is — note lib/filters/registry.ts has no parent facet, so a "children of X" query is not offered here; adding one is a registry card in the work-items epic, never an inline query). It ships no write, no sub-resource, and no OpenAPI operation (11.4 assembles the spec from the schema module). It does not change the /items view or search_work_items.

Acceptance criteria

  • GET /api/v1/projects/{projectKey}/work-items returns the list envelope for a read token; every row parses against workItemSummarySchema.
  • The one-grammar contract, asserted end to end: for an identical FilterAST, the endpoint walked to exhaustion returns the SAME key set as the /items view's own read for that filter — so the API can never disagree with the web app about what a filter means.
  • Paging a project larger than one page yields every row exactly once across the walk, and the last page carries nextCursor: null.
  • No unbounded read: a test asserts the endpoint issues a bounded query — the service is called with a limit, and a project seeded well past one page never has more than limit + 1 rows read for a page.
  • limit=100 returns up to 100 rows (not 50); limit=0, a negative, a fractional and a non-numeric value are each 422, and an oversized limit clamps rather than erroring.
  • A malformed, truncated, foreign or tampered cursor is 422 with a stable code — never a silent reset to the first page.
  • A malformed ?filter=, an unknown field id, an unknown operator, a bad value and an over-cap row count are each 422 with their own code, all in the { code, error } envelope.
  • An unknown or cross-workspace projectKey is 404; a project the caller cannot browse is the same 404.
  • An empty result is 200 with items: [] and nextCursor: null, never a 404.
  • The route passes the shipped architecture guard: wrapper-composed, scope declared, no Prisma, no transaction.
  • The per-file coverage floor (≥90%) holds on every new file.

Context refs

  • lib/filters/ast.tsdecodeFilterParam, FILTER_PARAM, FilterDecodeResult, FILTER_ROW_CAP.
  • lib/filters/registry.ts — the field set the AST validates against, and its absent parent facet.
  • lib/filters/errors.tsUnknownFilterFieldError / UnknownFilterOperatorError / InvalidFilterValueError / FilterTooLargeError / MalformedFilterError, the 422 codes to map.
  • lib/api/v1/pagination.tsparsePageRequest, encodePageCursor, ListEnvelope, MAX_PAGE_LIMIT; and why paginateKeyset is not used here.
  • lib/mcp/tools/searchWorkItems.ts — the shipped sibling that proves the AST → service path and the one-grammar discipline.
  • app/api/v1/workspaces/route.ts — the shipped list-envelope precedent.
  • Producers: 11.2.3 (the read) · 11.2.2 (the summary schema). Parent story: 11.2.