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

11.3.6 The sprint LIFECYCLE moves — `POST /api/v1/sprints/{sprintId}/start` and `/complete`, proven under REAL concurrency

The two state transitions that make a sprint a cadence rather than a container: activate it, and close it out with its unfinished work carried over. These are the only read-derived writes in 11.3 — both guard on the project's CURRENT active sprint before writing — so they are the endpoints where a concurrency test is the deliverable rather than a nicety.

Thin adapters over sprintsService.startSprint and completeSprint, returning the sprint schema 11.3.4 pins.

The concurrency guard is already SHIPPED — expose it, do not rebuild it

startSprint closes the TOCTOU window itself: a friendly findActiveByProject pre-check 409s early, and the authoritative guard is findActiveByProjectForUpdate — a SELECT … FOR UPDATE on the project's active sprint INSIDE the activation transaction — with the sprint_one_active_per_project partial-unique index as the DB backstop. completeSprint locks the same row the same way. So this card must not introduce a count-then-write or check-then-write guard at the route: the route parses, calls one service method, and maps the typed error. The story's completeness clause says this explicitly, and a route-level guard would be both redundant and racy.

What the card owes is the PROOF, at the HTTP boundary: two simultaneous starts, one 200 and one 409 with a typed { code, error } — never two 200s, never a raw Postgres unique-violation leaking as a 500. A serial test does not test this; the test must drive genuine concurrency against real Postgres and accept every legitimate ordering of the two outcomes.

Two shipped behaviours the API inherits — name them, do not re-decide them

  • startSprint idempotently provisions a scrum board before activating (boardsService.createBoard when the project has none), deliberately OUTSIDE the activation transaction because provisioning is independent and idempotent. The API inherits that side effect; it does not add, move or suppress it.
  • startSprint stamps the immutable baselinecommittedIssueCount and committedPoints — from the sprint's issues at activation. After this call those fields stop being null, which is the observable difference between a planned and a started sprint on every read in this story.

What to build

  • POST /api/v1/sprints/{sprintId}/start — body is StartSprintInput: startDate (defaults to now), endDate, and the optional inline name / goal edits the shipped start dialog performs inside the activation transaction. An action as a sub-path of the resource, matching POST /api/v1/work-items/{key}/archive (11.2.10) rather than inventing a new verb convention.
  • POST /api/v1/sprints/{sprintId}/complete — body is CompleteSprintInput: carryOverTo defaults to 'backlog', or { sprintId } to append the unfinished issues to an existing planned sprint in the same project. Done-category issues always stay on the completed sprint — that is the historical record, and the response must not suggest otherwise.

Both are sprints:write and sprint-admin gated, exactly as 11.3.5's pair.

Acceptance criteria

  • Both endpoints exist, declare scope: 'sprints:write', and refuse a read-only token with 403; a sprints:write token whose owner is not a sprint admin is refused 403 with a distinguishable code.
  • Two simultaneous starts against the same project: exactly one succeeds; the loser receives a 409 with a typed { code, error } — driven concurrently against real Postgres, with both orderings accepted, and asserting the loser's body is the mapped domain error rather than a 500 or a raw driver message.
  • Starting a sprint that is not planned is a 422 carrying the shipped SprintNotStartableError; an invalid window is a 422; each is raised by the service and has a deliberate row in the v1 status map.
  • After a successful start the sprint reads back state: 'active' with non-null committedIssueCount / committedPoints (and committedPoints: null only when the sprint was wholly unestimated) — the baseline is observable through the API, not just in the database.
  • Completing with the default destination leaves done-category issues on the sprint and returns the unfinished ones to the backlog in rank order; completing into a planned sprint appends them there; completing into a non-planned or cross-project sprint is a typed 422, not a 500.
  • No route calls Prisma, opens a transaction, or performs any check-then-write of its own — asserted by 11.1's shipped tree-wide guard plus a reading of this card's diff.
  • 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/sprintsService.tsstartSprint (the pre-check, findActiveByProjectForUpdate, the board provisioning, the baseline snapshot) and completeSprint (the same lock, the carry-over).
  • lib/repositories/sprintRepository.tsfindActiveByProjectForUpdate, and the sprint_one_active_per_project partial-unique backstop.
  • lib/dto/sprints.tsStartSprintInput, CompleteSprintInput, CarryOverDestination, assertSprintTransition.
  • lib/api/v1/errors.ts — where SprintAlreadyActiveError (409) / SprintNotStartableError (422) / SprintWindowInvalidError (422) each need a deliberate row.
  • motir-core/CLAUDE.md § concurrency — lock the row before a read-derived update, and translate a lost race into a typed domain error.
  • Blocker: 11.3.4. Action-path precedent: 11.2.10. Parent story: 11.3.