Module: versions¶
Purpose¶
Manages project-scoped release versions. Versions can be attached to issues in two distinct roles: fix versions (the release that resolves an issue) and affects versions (the releases where the problem is observed). Both roles are stored in a single join table with a type discriminator. Version CRUD is available from the dedicated settings page (VersionsPage), accessible via the project sidebar. Versions cannot be created on the fly from the issue detail view — they must be pre-created in settings.
Entities Owned¶
| Entity | Table | Key Fields |
|---|---|---|
Version |
versions |
name VARCHAR(50) NOT NULL, project FK→projects NOT NULL; UNIQUE (project_id, name) |
IssueVersion |
issue_versions |
issue_id UUID, version_id UUID, type VARCHAR(8) CHECK IN ('FIX','AFFECTS'); PRIMARY KEY (issue_id, version_id, type) |
The issue_versions table is owned by the versions module, not by Issue. Both fix and affects assignments share this single table, differentiated by the type column.
DB Schema¶
versions (V24)¶
CREATE TABLE versions (
id UUID NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY,
project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
name VARCHAR(50) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (project_id, name)
);
issue_versions (V24)¶
Single join table for both fix and affects relationships. The type column is part of the primary key.
| Column | Type | Constraint |
|---|---|---|
issue_id |
UUID | FK→issues ON DELETE CASCADE |
version_id |
UUID | FK→versions ON DELETE CASCADE |
type |
VARCHAR(8) | CHECK (type IN ('FIX', 'AFFECTS')) |
Primary key: (issue_id, version_id, type).
CREATE TABLE issue_versions (
issue_id UUID NOT NULL REFERENCES issues(id) ON DELETE CASCADE,
version_id UUID NOT NULL REFERENCES versions(id) ON DELETE CASCADE,
type VARCHAR(8) NOT NULL CHECK (type IN ('FIX', 'AFFECTS')),
PRIMARY KEY (issue_id, version_id, type)
);
Module Layout¶
versions/
domain/
Version.kt — JPA entity
IssueVersion.kt — join table entity (@IdClass composite PK)
IssueVersionId.kt — composite PK data class (Serializable)
infrastructure/
VersionRepository.kt — findByProjectId, existsByProjectIdAndName, findByIssueIdAndType (native)
IssueVersionRepository.kt — deleteByIssueIdAndType (@Modifying JPQL)
application/
VersionService.kt — CRUD logic; checks project membership for all ops
api/
VersionController.kt
dto/VersionRequest.kt — {name}
dto/VersionResponse.kt — {id, name}
API Endpoints¶
VersionController — /api/v1/projects/{key}/versions¶
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | /api/v1/projects/{key}/versions |
USER | Lists all versions for the project |
| POST | /api/v1/projects/{key}/versions |
USER | Creates a version; returns 201 Created; 409 if name already exists in project |
| PUT | /api/v1/projects/{key}/versions/{id} |
USER | Renames a version; 409 on name conflict |
| DELETE | /api/v1/projects/{key}/versions/{id} |
USER | Deletes version; returns 204 No Content; issue_versions rows removed by DB cascade |
All endpoints require project membership (ProjectService.requireMember()).
Events Emitted¶
None directly. Version assignment changes on issues are recorded as IssueFieldChangedEvent by IssueService.update() in the issues module (field names "fixVersions" and "affectsVersions").
Events Consumed¶
None.
Issue Integration¶
PATCH /api/v1/projects/{key}/issues/{id}¶
UpdateIssueRequest carries two optional version lists:
data class UpdateIssueRequest(
// ...
val fixVersionIds: List<UUID>? = null,
val affectsVersionIds: List<UUID>? = null
)
Semantics follow the same null-vs-empty pattern as labelIds:
null— field is absent from the PATCH; the existing version set is untouched.[](empty list) — explicitly clears all versions of that type.- Non-empty list — replaces the current set with the supplied version IDs.
IssueService.update() resolves each list against VersionRepository.findAllById(), filters to versions belonging to the current project, then calls IssueVersionRepository.deleteByIssueIdAndType() and re-inserts only if the resolved name set has actually changed.
Versions from a different project in the supplied IDs are silently dropped — no error is returned; only valid versions are applied.
GET /api/v1/projects/{key}/issues/{issueKey}¶
IssueController.get() fetches both version sets explicitly via native SQL before building the response:
val fixVersions = versionRepository.findByIssueIdAndType(issue.id, "FIX")
.map { VersionResponse.from(it) }
val affectsVersions = versionRepository.findByIssueIdAndType(issue.id, "AFFECTS")
.map { VersionResponse.from(it) }
return IssueResponse.from(issue, refs, labels, fixVersions, affectsVersions)
IssueResponse includes fixVersions: List<VersionResponse> and affectsVersions: List<VersionResponse>.
Issue List Filter¶
GET /api/v1/projects/{key}/issues accepts two optional version filter params:
| Param | Type | Description |
|---|---|---|
fixVersionId |
UUID (optional) | Return only issues where this version is a fix version |
affectsVersionId |
UUID (optional) | Return only issues where this version is an affects version |
Both params are AND-combinable — supplying both narrows results to issues that match on both axes simultaneously. Version filters take priority over the existing labelId filter — if any version filter param is present, labelId is not applied.
Key Files¶
| File | Purpose |
|---|---|
backend/src/main/resources/db/migration/V24__versions.sql |
Creates versions and issue_versions tables |
backend/src/main/kotlin/com/taskowolf/versions/domain/Version.kt |
JPA entity |
backend/src/main/kotlin/com/taskowolf/versions/domain/IssueVersion.kt |
Join table entity with composite PK |
backend/src/main/kotlin/com/taskowolf/versions/domain/IssueVersionId.kt |
Composite PK data class |
backend/src/main/kotlin/com/taskowolf/versions/infrastructure/VersionRepository.kt |
findByProjectId, existsByProjectIdAndName, findByIssueIdAndType (native SQL) |
backend/src/main/kotlin/com/taskowolf/versions/infrastructure/IssueVersionRepository.kt |
deleteByIssueIdAndType (@Modifying JPQL) |
backend/src/main/kotlin/com/taskowolf/versions/application/VersionService.kt |
CRUD logic |
backend/src/main/kotlin/com/taskowolf/versions/api/VersionController.kt |
REST controller |
backend/src/main/kotlin/com/taskowolf/versions/api/dto/VersionRequest.kt |
{name} for POST/PUT |
backend/src/main/kotlin/com/taskowolf/versions/api/dto/VersionResponse.kt |
{id, name} |
backend/src/test/kotlin/com/taskowolf/versions/VersionServiceTest.kt |
Unit tests |
frontend/src/api/versions.ts |
versionsApi — list, create, update, delete HTTP calls |
Key Design Decisions¶
- Single join table with type discriminator. Using one
issue_versionstable with atypecolumn (rather than two separate tablesissue_fix_versions/issue_affects_versions) keeps the schema compact and allows both roles to be queried with the same repository interface. Thetypecolumn is part of the primary key, so the same version can appear as both a fix version and an affects version on the same issue. - No color field. Versions are plain named entries — there is no color field (contrast with
labels). TheVersionResponsereturns only{id, name}. - No on-the-fly creation. Labels can be created inline from the issue detail selector; versions cannot. Versions must be created from the
VersionsPagesettings UI before they can be assigned to issues.
Common Pitfalls¶
VersionRepositoryis injected in two cross-module locations.IssueController.get()usesfindByIssueIdAndType(native SQL) to load both version sets for the single-issue GET.IssueService.update()usesfindAllByIdto resolve incoming version IDs before saving. Both are deliberate exceptions to the no-cross-module-injection rule.IssueVersionRepositoryis injected intoIssueServiceas a cross-module dependency. LikeVersionRepository, this is a deliberate exception to enforce transactional cleanup of version assignments.IssueService.update()callsdeleteByIssueIdAndType()before re-inserting to detect actual changes and emit appropriate events.nullvs empty list onUpdateIssueRequest.fixVersionIds/affectsVersionIds.null= no change;[]= clear all versions of that type. The service checksrequest.fixVersionIds != nullbefore touching the set.- Versions from a different project are silently dropped.
IssueService.update()filters resolved versions byit.project.id == project.id. No error is returned — the call succeeds and only valid versions are applied. IssueVersionRepository.deleteByIssueIdAndTypeis a@ModifyingJPQL query. It requires an active transaction and must run beforesaveAllin the same transaction. Do not call it outside a@Transactionalcontext.
Frontend Integration¶
| Concept | Detail |
|---|---|
| API client | frontend/src/api/versions.ts — versionsApi.list, create, update, delete |
| Hooks | useVersions, useCreateVersion, useUpdateVersion, useDeleteVersion (query key: ['versions', projectKey]) |
VersionChip |
Read-only pill showing a version name |
VersionSelector |
Dropdown for assigning fix/affects versions on the issue detail sidebar |
VersionsPage |
Settings page at /projects/{key}/settings/versions; full CRUD |
| Issue list filter | IssueListPage stores active version filters in ?fixVersionId=<UUID> and ?affectsVersionId=<UUID> search params |
Example¶
// Create a version then mark it as a fix version on an issue
val version = versionService.create("WOLF", VersionRequest("v1.1"), actor)
issueService.update(
"WOLF", issueId,
UpdateIssueRequest(fixVersionIds = listOf(version.id)),
actor
)
Test Patterns¶
| File | What is tested |
|---|---|
VersionServiceTest |
list returns versions for the correct project |
VersionServiceTest |
create saves a new version |
VersionServiceTest |
create throws ConflictException when name already exists in project |
VersionServiceTest |
update renames a version |
VersionServiceTest |
update throws ConflictException when new name already exists in project |
VersionServiceTest |
delete removes the version |
VersionServiceTest |
delete throws NotFoundException when version belongs to a different project |
IssueServiceTest |
update sets fix versions when fixVersionIds is a non-empty list |
IssueServiceTest |
update clears fix versions when fixVersionIds is an empty list |
IssueServiceTest |
update sets affects versions when affectsVersionIds is a non-empty list |