Module: sprints¶
Purpose¶
Manages sprint lifecycle (PLANNED → ACTIVE → CLOSED). Owns backlog: issues not assigned to any sprint. Snapshots plannedPoints at sprint start and calculates completedPoints on close.
Entities Owned¶
| Entity | Table | Key Fields |
|---|---|---|
Sprint |
sprints |
name VARCHAR(255) NOT NULL, goal TEXT nullable, status: SprintStatus NOT NULL DEFAULT PLANNED, startDate: LocalDate?, endDate: LocalDate?, plannedPoints: Int? (snapshot at start), completedPoints: Int? (computed at close), project FK→projects NOT NULL |
SprintStatus enum values: PLANNED, ACTIVE, CLOSED.
DB Schema¶
sprints (V5)¶
CREATE TABLE sprints (
id UUID NOT NULL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
goal TEXT,
status VARCHAR(20) NOT NULL DEFAULT 'PLANNED',
start_date DATE,
end_date DATE,
planned_points INT,
completed_points INT,
project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL
);
Index: idx_sprints_project on project_id.
V5 also adds FK constraint fk_issues_sprint (issues.sprint_id → sprints.id ON DELETE SET NULL) and index idx_issues_sprint on issues.sprint_id.
API Endpoints¶
SprintController — /api/v1/projects/{key}/sprints¶
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | /api/v1/projects/{key}/sprints |
USER | Lists all sprints for the project in creation order |
| POST | /api/v1/projects/{key}/sprints |
USER | Creates a sprint in PLANNED state |
| PATCH | /api/v1/projects/{key}/sprints/{sprintId} |
USER | Updates name, goal, startDate, endDate; date changes rejected if sprint is not PLANNED |
| POST | /api/v1/projects/{key}/sprints/{sprintId}/start |
USER | Transitions PLANNED→ACTIVE; snapshots plannedPoints; rejects if ACTIVE sprint already exists |
| POST | /api/v1/projects/{key}/sprints/{sprintId}/complete |
USER | Transitions ACTIVE→CLOSED; moves non-DONE issues to backlog; returns movedToBacklogCount |
| PUT | /api/v1/projects/{key}/sprints/{sprintId}/issues/{issueId} |
USER | Assigns an issue to this sprint; rejected if sprint is CLOSED |
| DELETE | /api/v1/projects/{key}/sprints/{sprintId}/issues/{issueId} |
USER | Removes an issue from this sprint |
Events Emitted¶
SprintStartedEvent¶
Published by SprintService.start() after transitioning to ACTIVE.
| Field | Type | Description |
|---|---|---|
sprint |
Sprint |
The sprint entity after status change |
actorEmail |
String |
Email of the user who triggered the start |
actorId |
UUID |
ID of the user who triggered the start |
SprintCompletedEvent¶
Published by SprintService.complete() after transitioning to CLOSED.
| Field | Type | Description |
|---|---|---|
sprint |
Sprint |
The sprint entity after status change |
movedToBacklogCount |
Int |
Number of non-DONE issues moved back to backlog |
actorEmail |
String |
Email of the user who completed the sprint |
actorId |
UUID |
ID of the user who completed the sprint |
Events Consumed¶
None. No @EventListener annotations exist in sprints/application/SprintService.kt.
Key Files¶
| File | Responsibility |
|---|---|
backend/src/main/kotlin/com/taskowolf/sprints/domain/Sprint.kt |
@Entity; all lifecycle fields (status, startDate, plannedPoints, completedPoints) are mutable var |
backend/src/main/kotlin/com/taskowolf/sprints/domain/SprintStatus.kt |
Enum { PLANNED, ACTIVE, CLOSED } |
backend/src/main/kotlin/com/taskowolf/sprints/domain/events/SprintStartedEvent.kt |
Data class emitted on sprint start |
backend/src/main/kotlin/com/taskowolf/sprints/domain/events/SprintCompletedEvent.kt |
Data class emitted on sprint close; includes movedToBacklogCount |
backend/src/main/kotlin/com/taskowolf/sprints/application/SprintService.kt |
All business logic: lifecycle transitions, issue assignment, backlog management |
backend/src/main/kotlin/com/taskowolf/sprints/api/SprintController.kt |
REST endpoints mapping directly to SprintService |
backend/src/main/kotlin/com/taskowolf/sprints/api/dto/CreateSprintRequest.kt |
name required; goal, startDate, endDate optional |
backend/src/main/kotlin/com/taskowolf/sprints/api/dto/UpdateSprintRequest.kt |
All fields nullable; partial update |
backend/src/main/kotlin/com/taskowolf/sprints/api/dto/SprintResponse.kt |
Full sprint DTO |
backend/src/main/kotlin/com/taskowolf/sprints/api/dto/SprintCompleteResponse.kt |
Wraps SprintResponse with movedToBacklogCount: Int |
backend/src/main/kotlin/com/taskowolf/sprints/infrastructure/SprintRepository.kt |
findByProjectId, findByProjectIdAndStatus, existsByProjectIdAndStatus |
Extension Points¶
To add sprint metadata (e.g. a retrospective URL or velocity target):
- Add
@Column var newField: Ttobackend/src/main/kotlin/com/taskowolf/sprints/domain/Sprint.kt. - Add a Flyway migration (V23+) that ALTERs the
sprintstable to add the column. - Add the field to
SprintResponseinbackend/src/main/kotlin/com/taskowolf/sprints/api/dto/SprintResponse.kt. - Add the field (nullable) to
UpdateSprintRequestand handle it inSprintService.update().
Common Pitfalls¶
- Only one sprint can be ACTIVE per project at a time.
SprintService.start()callssprintRepository.existsByProjectIdAndStatus(project.id, SprintStatus.ACTIVE)and throwsConflictExceptionif true. Never bypass this check. - DO NOT delete a sprint. Close it via
POST /{sprintId}/complete. Deleting a sprint setssprint_idto NULL on all its issues (ON DELETE SET NULL) and permanently loses sprint association history. plannedPointsis a snapshot taken at start time viaissueRepository.sumStoryPointsBySprintId(sprint.id). Adding or removing issues after start does not updateplannedPoints.completedPointsis computed at close time from issues whosestatus.category == DONE. It is not kept in sync during the sprint.- Sprint dates (
startDate,endDate) can only be changed while status isPLANNED.update()throwsConflictExceptionif dates are provided for a non-PLANNED sprint. - Issues cannot be assigned to a CLOSED sprint;
assignIssue()throwsConflictExceptionwhen sprint status isCLOSED.
Example¶
Sprint start transition in SprintService.start():
fun start(projectKey: String, sprintId: UUID, actor: User): Sprint {
val project = projectService.requireMember(projectKey, actor.id)
val sprint = requireSprint(sprintId, project.id)
if (sprint.status != SprintStatus.PLANNED) throw ConflictException("Sprint is not in PLANNED state")
if (sprintRepository.existsByProjectIdAndStatus(project.id, SprintStatus.ACTIVE))
throw ConflictException("Project already has an active sprint")
sprint.status = SprintStatus.ACTIVE
if (sprint.startDate == null) sprint.startDate = LocalDate.now()
sprint.plannedPoints = issueRepository.sumStoryPointsBySprintId(sprint.id).toInt()
val saved = sprintRepository.save(sprint)
eventPublisher.publish(SprintStartedEvent(saved, actorEmail = actor.email, actorId = actor.id))
return saved
}
Sprint close in SprintService.complete() — non-DONE issues nulled back to backlog, completedPoints captured:
fun complete(projectKey: String, sprintId: UUID, actor: User): SprintCompleteResult {
val project = projectService.requireMember(projectKey, actor.id)
val sprint = requireSprint(sprintId, project.id)
if (sprint.status != SprintStatus.ACTIVE) throw ConflictException("Sprint is not ACTIVE")
val allIssues = issueRepository.findBySprintId(sprint.id)
val openIssues = allIssues.filter { it.status.category != StatusCategory.DONE }
openIssues.forEach { it.sprint = null }
issueRepository.saveAll(openIssues)
sprint.completedPoints = allIssues
.filter { it.status.category == StatusCategory.DONE }.sumOf { it.storyPoints ?: 0 }
sprint.status = SprintStatus.CLOSED
val saved = sprintRepository.save(sprint)
eventPublisher.publish(SprintCompletedEvent(sprint = saved, movedToBacklogCount = openIssues.size, actorEmail = actor.email, actorId = actor.id))
return SprintCompleteResult(saved, openIssues.size)
}
Test Patterns¶
Unit tests (MockK, no Spring context)¶
| File | What is tested |
|---|---|
SprintServiceTest |
start throws ConflictException when an ACTIVE sprint already exists for the project |
SprintServiceTest |
start sets status to ACTIVE and snapshots plannedPoints from sumStoryPointsBySprintId |
SprintServiceTest |
complete moves non-DONE issues to backlog (sprint = null) and returns correct movedToBacklogCount |
SprintServiceTest |
create persists sprint with the correct project reference |
Integration tests (Spring Boot Test + MockMvc + real DB, extends IntegrationTestBase)¶
| File | What is tested |
|---|---|
SprintLifecycleIntegrationTest |
Full lifecycle: create sprint, assign two issues (8 total points), start (assert plannedPoints=8), move one issue to DONE via board, complete (assert movedToBacklogCount=1, status=CLOSED), verify backlog contains the open issue, verify velocity report has one entry with completedPoints=5 |