feat: move unfinished tickets between releases

- Bulk editing gets «Release…» (open releases or none), and the list
  view a release filter, also reachable from each release («Ver en la
  lista», ?release=).
- Archiving a release asks where its unfinished tickets go, as closing
  a sprint does: ReleaseArchivedEvent carries `moveTo` (an open release
  of the same project, or none) and the saga moves them there; finished
  ones stay. In GraphQL, REST and the MCP's archive_release.
- Chapter 10 lists the next features and the ideas to weigh.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-14 21:55:45 +02:00
parent 0a73eda9b2
commit 993f80b4b9
34 changed files with 408 additions and 48 deletions

View File

@@ -1,10 +1,19 @@
import { AutowiredEvent } from '@task-stream/api/shared/domain';
import { TrackerEvent } from './tracker.event';
/** The release was archived. */
/**
* The release was archived. Its unfinished tickets move to `moveTo` (an
* open release of the same project) or, when null, leave it. Events stored
* before `moveTo` existed read as null.
*/
@AutowiredEvent
export class ReleaseArchivedEvent extends TrackerEvent {
constructor(actorId: string, releaseId: string, projectId: string) {
constructor(
actorId: string,
releaseId: string,
projectId: string,
readonly moveTo: string | null = null,
) {
super(actorId, releaseId, projectId);
}
}

View File

@@ -132,9 +132,13 @@ export class Release extends VersionedAggregateRoot implements ReleaseProps {
);
}
archive(actorId: string): void {
/** @param moveTo where its unfinished tickets go (null: no release) */
archive(actorId: string, moveTo: string | null = null): void {
if (this.status === ReleaseStatus.ARCHIVED) return;
this.apply(new ReleaseArchivedEvent(actorId, this.id, this.projectId));
assertThat(moveTo !== this.id, 'A release cannot take its own tickets');
this.apply(
new ReleaseArchivedEvent(actorId, this.id, this.projectId, moveTo),
);
}
/** Deletes the release (its project was deleted). Idempotent. */