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-2064Done

11.3.7 Sprint MEMBERSHIP in both directions — `POST /api/v1/sprints/{sprintId}/work-items` and `POST /api/v1/projects/{projectKey}/backlog/work-items`, atomic per batch

Moving work in and out of a sprint — the operation a cadence script performs most often, and the one place in 11.3 where a partial success would be worse than a refusal.

Thin adapters over backlogService.bulkAssignToSprint and bulkMoveToBacklog, both of which are already all-or-nothing: the whole batch is loaded and validated before any write, and every write plus its 1.4.6 revision commits in one transaction. This card exposes that atomicity; it does not build it, and it must not weaken it by looping per item at the route.

Two paths, both POST — and why not DELETE

Membership is a property of the work item, and the two directions are two destinations rather than a create and a delete. POST /api/v1/sprints/{sprintId}/work-items moves a batch INTO a sprint; POST /api/v1/projects/{projectKey}/backlog/work-items moves a batch back to the backlog. A DELETE with a body is the alternative and is rejected for the reason the shipped surface already rejected it once: 11.2 paired POST .../archive with POST .../restore rather than overloading DELETE, and a batch of ids does not belong in a DELETE body. Both paths take { workItemKeys: [...] }MOTIR-<n> keys, never internal cuids (ADR §7), resolved to ids at the route the same way the shipped work-item create resolves a parentKey.

What the shipped services already enforce — assert it, do not re-implement it

  • Batch cap 100 (MAX_BULK_BATCH_SIZE), raising BulkBatchTooLargeError. Map it deliberately; do not silently truncate.
  • Empty input is a guarded no-op returning [], not an error.
  • Duplicate ids collapse.
  • Cross-project membership is refused (CrossProjectSprintAssignmentError, 422) and an unknown or cross-workspace member is a 404 — either one rejects the ENTIRE batch before any write.
  • Placement: an assignment without a placement appends to the sprint's rank tail, chaining keyForAppend so each item ranks strictly after the previous — bounded single-row writes, never an N-row renumber. A move back to the backlog KEEPS the item's backlogRank, so it re-appears in the backlog in order; an item already in the backlog is a per-item no-op with no write and no revision.

Whether v1 exposes the neighbour-based RankPlacementInput at all is this card's call: the story ships no re-rank endpoint, and appending to the tail is the honest default for an API client that cannot see the board. If placement is NOT exposed, say so in the module rather than leaving it as an unexplained omission — under §8 adding it later is additive.

Independently buildable — read back through the SERVICE, not through a sibling's endpoint

The natural way to check a move landed is to read the collection back, and the v1 collection endpoints are a sibling card's deliverable. Do not reach for them: this card's assertions read back through the shipped backlogService.getSprintIssues / getBacklog reads, which is the right level for a route unit test anyway. The end-to-end form — move over HTTP, then read over HTTP — belongs to the story's conformance suite, which is blocked_by both cards and is where a cross-endpoint journey is supposed to be proven.

Acceptance criteria

  • Both endpoints exist, declare scope: 'sprints:write', and refuse a read-only token with 403.
  • A batch containing one unknown, cross-workspace or cross-project member lands NOTHING — asserted by reading every other member back afterwards and finding it unmoved, not merely by checking the status code. This is the atomicity claim, and only a read-back proves it.
  • The batch cap is enforced with a typed 400/422 naming the cap; 101 ids is refused, 100 succeeds.
  • An empty workItemKeys array is a 200 no-op, not a 422.
  • Duplicate keys in one request collapse to one move each.
  • Items assigned without a placement land at the sprint's rank TAIL in request order — asserted by reading the members back through backlogService.getSprintIssues and comparing the order.
  • An item moved back to the backlog re-appears in backlogService.getBacklog at its original rank position; moving an already-backlog item is a no-op that records no revision.
  • Every response body is the declared schema's output and carries MOTIR-<n> keys, no internal cuids.
  • No route calls Prisma, opens a transaction, or loops a per-item service call — one service call per request, after key resolution.
  • Unit tests ship with the routes; every new file holds the ≥90% per-file coverage floor.
  • ONE PR against motir-core.

Context refs

  • lib/services/backlogService.tsbulkAssignToSprint (the load-and-validate-then-write shape, keyForAppend, MAX_BULK_BATCH_SIZE), bulkMoveToBacklog, assignToSprint / moveToBacklog (the single-item semantics the bulk paths compose), resolveRank, and the getSprintIssues / getBacklog reads this card's tests read back through.
  • lib/dto/backlog.tsRankPlacementInput and the recorded meaning of beforeId / afterId.
  • lib/services/workItemsService.tsgetWorkItemByIdentifier, the MOTIR-<n> → id resolution the shipped create route uses.
  • lib/api/v1/errors.ts — where BulkBatchTooLargeError / CrossProjectSprintAssignmentError / SprintNotFoundError each need a deliberate row.
  • app/api/v1/work-items/[key]/archive/route.ts — the shipped precedent for a POST action pair over a DELETE.
  • Blocker: 11.3.4 (the sprint resource path + its tenancy resolution). Parent story: 11.3.