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

@@ -11,10 +11,12 @@ export class ArchiveReleaseCommandHandler
async execute(command: ArchiveReleaseCommand): Promise<CommandResult> {
const release = await this.access.release(command.releaseId);
(await this.access.project(release.projectId)).assertCanManage(
command.actorId,
);
release.archive(command.actorId);
const project = await this.access.project(release.projectId);
project.assertCanManage(command.actorId);
// The tickets can only go to an open release of the same project.
if (command.moveTo && command.moveTo !== release.id)
await this.access.openReleaseOf(project, command.moveTo);
release.archive(command.actorId, command.moveTo);
await release.commitAndWait();
return ack(release);
}

View File

@@ -5,6 +5,8 @@ export class ArchiveReleaseCommand extends Command<CommandResult> {
constructor(
public readonly actorId: string,
public readonly releaseId: string,
/** Open release of the same project for its unfinished tickets. */
public readonly moveTo: string | null = null,
) {
super();
}

View File

@@ -15,7 +15,21 @@ export class DetachIssueFromReleaseCommandHandler
const project = await this.access.live(issue.projectId, Project);
// Finished tickets stay: they are what the release shipped.
if (!project || project.workflow.isFinished(issue.status)) return;
issue.setRelease(command.actorId, null);
issue.setRelease(command.actorId, await this.target(project, command));
await issue.commitAndWait();
}
/** The release chosen when archiving, if it still takes tickets. */
private async target(
project: Project,
command: DetachIssueFromReleaseCommand,
): Promise<string | null> {
if (!command.toReleaseId) return null;
try {
await this.access.openReleaseOf(project, command.toReleaseId);
return command.toReleaseId;
} catch {
return null;
}
}
}

View File

@@ -1,11 +1,15 @@
import { Command } from '@nestjs/cqrs';
/** Saga only: takes an unfinished ticket out of an archived release. */
/**
* Saga only: takes an unfinished ticket out of an archived release, to
* `toReleaseId` or to none.
*/
export class DetachIssueFromReleaseCommand extends Command<void> {
constructor(
public readonly actorId: string,
public readonly issueId: string,
public readonly releaseId: string,
public readonly toReleaseId: string | null = null,
) {
super();
}

View File

@@ -66,9 +66,14 @@ export class ReleasesService {
);
}
archive(actorId: string, releaseId: string): Promise<CommandResult> {
/** @param moveTo open release for its unfinished tickets (none: they leave it) */
archive(
actorId: string,
releaseId: string,
moveTo: string | null = null,
): Promise<CommandResult> {
return this.commandBus.execute(
new ArchiveReleaseCommand(actorId, releaseId),
new ArchiveReleaseCommand(actorId, releaseId, moveTo),
);
}

View File

@@ -7,8 +7,9 @@ import { ReleaseIssuesRepository } from '../ports/release-issues.repository';
/**
* An archived release no longer accepts changes, so its unfinished tickets
* would be stuck in it: they leave it (back to "no release"), attributed to
* whoever archived it. Finished tickets stay, as the record of what shipped.
* would be stuck in it: they move to the release chosen when archiving it,
* or back to "no release", attributed to whoever archived it. Finished
* tickets stay, as the record of what shipped.
*/
@Injectable()
export class ReleaseArchivedSaga {
@@ -27,6 +28,7 @@ export class ReleaseArchivedSaga {
event.actorId,
issueId,
event.aggregateId,
event.moveTo ?? null,
),
),
),

View File

@@ -306,6 +306,45 @@ describe('sagas', () => {
expect((await rehydrator.rehydrate(done, Issue)).releaseId).toBe(release);
});
it('archiving a release can hand its unfinished tickets to another', async () => {
const { run, drain, rehydrator } = await setup();
const web = (await run(new CreateProjectCommand('owner', 'WEB', 'Web'))).id;
const old = (
await run(new CreateReleaseCommand('owner', web, 'v1', null, null))
).id;
const next = (
await run(new CreateReleaseCommand('owner', web, 'v2', null, null))
).id;
const open = (
await run(
new CreateIssueCommand('owner', web, 'TASK', 'open', {}, null, old),
)
).id;
const done = (
await run(
new CreateIssueCommand('owner', web, 'TASK', 'done', {}, null, old),
)
).id;
for (const status of ['TODO', 'IN_PROGRESS', 'IN_REVIEW', 'DONE'])
await run(new ChangeIssueStatusCommand('owner', done, status));
// Only an open release of the same project takes them.
const other = (await run(new CreateProjectCommand('owner', 'API', 'Api')))
.id;
const elsewhere = (
await run(new CreateReleaseCommand('owner', other, 'v9', null, null))
).id;
await expect(
run(new ArchiveReleaseCommand('owner', old, elsewhere)),
).rejects.toThrow(/another project/);
await run(new ArchiveReleaseCommand('owner', old, next));
await drain();
expect((await rehydrator.rehydrate(open, Issue)).releaseId).toBe(next);
expect((await rehydrator.rehydrate(done, Issue)).releaseId).toBe(old);
});
it('a user deactivated in Pireon leaves their open tickets unassigned', async () => {
const { run, react, rehydrator, openAssigned } = await setup();
const web = (await run(new CreateProjectCommand('owner', 'WEB', 'Web'))).id;

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. */

View File

@@ -146,7 +146,14 @@ export class ReleaseResolver {
archiveRelease(
@CurrentUser() me: User,
@Args('releaseId', { type: () => ID }) releaseId: string,
@Args('moveTo', {
type: () => ID,
nullable: true,
description:
'Open release of the same project that takes its unfinished tickets; none: they leave it',
})
moveTo?: string | null,
) {
return this.releases.archive(me.id, releaseId);
return this.releases.archive(me.id, releaseId, moveTo ?? null);
}
}

View File

@@ -1148,6 +1148,19 @@ export class SetIssueSprintDto {
sprintId!: string | null;
}
export class ArchiveReleaseDto {
@ApiPropertyOptional({
type: String,
nullable: true,
format: 'uuid',
description:
'Open release of the same project that takes its unfinished tickets; null or absent: they leave it',
})
@IsOptional()
@IsUUID()
moveTo?: string | null;
}
export class PublishReleaseDto {
@ApiPropertyOptional({
type: Boolean,

View File

@@ -34,6 +34,7 @@ import {
import {
CreateReleaseDto,
PageQueryDto,
ArchiveReleaseDto,
PublishReleaseDto,
ReleaseNotesDto,
UpcomingReleasesQueryDto,
@@ -234,10 +235,18 @@ export class ReleasesController {
@Post('releases/:releaseId/archive')
@HttpCode(200)
@ApiOperation({ summary: 'Archive a planned release (its tickets leave it)' })
@ApiOperation({
summary: 'Archive a planned release',
description:
'Its unfinished tickets move to `moveTo` (an open release of the same project) or leave it.',
})
@ApiOkResponse({ type: CommandResultDto })
@ApiCommandErrors()
archive(@CurrentUser() me: User, @Param('releaseId') releaseId: string) {
return this.releases.archive(me.id, releaseId);
archive(
@CurrentUser() me: User,
@Param('releaseId') releaseId: string,
@Body() body: ArchiveReleaseDto,
) {
return this.releases.archive(me.id, releaseId, body?.moveTo ?? null);
}
}

View File

@@ -26,7 +26,10 @@ export const projectPageEvents = eventGroup({
memberRemoveRequested: type<{ projectId: string; userId: string }>(),
releaseCreateRequested: type<CreateReleaseInput>(),
releasePublishRequested: type<{ releaseId: string; force: boolean }>(),
releaseArchiveRequested: type<{ releaseId: string }>(),
releaseArchiveRequested: type<{
releaseId: string;
moveTo: string | null;
}>(),
releaseNotesWriteRequested: type<{
releaseId: string;
notes: string | null;

View File

@@ -144,8 +144,9 @@ export class ProjectFacade {
return this.releasesGateway.notesDraft(releaseId);
}
archiveRelease(releaseId: string): void {
this.dispatch.releaseArchiveRequested({ releaseId });
/** Its unfinished tickets go to `moveTo` (an open release) or leave it. */
archiveRelease(releaseId: string, moveTo: string | null = null): void {
this.dispatch.releaseArchiveRequested({ releaseId, moveTo });
}
/** Replaces statuses, transitions, types and areas (owners and admins). */

View File

@@ -550,7 +550,7 @@ export const ProjectStore = signalStore(
.pipe(
mergeMap(({ payload }) =>
runCommand(
releases.archive(payload.releaseId),
releases.archive(payload.releaseId, payload.moveTo),
'Release archivada',
projectApiEvents.commandSucceeded,
projectApiEvents.commandFailed,

View File

@@ -96,6 +96,7 @@ export interface IssueListFilter {
priorities?: IssuePriority[];
assigneeId?: string;
unassigned?: boolean;
releaseId?: string;
limit: number;
}
@@ -134,6 +135,8 @@ export interface BulkChange {
assigneeId?: string | null;
/** null: back to the backlog. */
sprintId?: string | null;
/** null: out of its release. */
releaseId?: string | null;
priority?: IssuePriority;
addLabels?: string[];
}

View File

@@ -289,7 +289,11 @@ export abstract class ReleasesGateway {
releaseId: string,
force: boolean,
): Observable<CommandResult>;
abstract archive(releaseId: string): Observable<CommandResult>;
/** Its unfinished tickets go to `moveTo` (an open release) or leave it. */
abstract archive(
releaseId: string,
moveTo?: string | null,
): Observable<CommandResult>;
abstract burnup(releaseId: string): Observable<ReleaseBurnup>;
/** A Markdown draft from the release's finished tickets (not saved). */
abstract notesDraft(releaseId: string): Observable<string>;

View File

@@ -104,9 +104,12 @@ export class GraphqlReleasesGateway extends ReleasesGateway {
.pipe(map((d) => toCommandResult(d.publishRelease)));
}
archive(releaseId: string): Observable<CommandResult> {
archive(
releaseId: string,
moveTo: string | null = null,
): Observable<CommandResult> {
return this.gql
.mutate(ArchiveReleaseDocument, { releaseId })
.mutate(ArchiveReleaseDocument, { releaseId, moveTo })
.pipe(map((d) => toCommandResult(d.archiveRelease)));
}

View File

@@ -255,6 +255,8 @@ export class GraphqlIssuesGateway extends IssuesGateway {
assigneeId: change.assigneeId ?? null,
noSprint: change.sprintId === null ? true : null,
sprintId: change.sprintId ?? null,
noRelease: change.releaseId === null ? true : null,
releaseId: change.releaseId ?? null,
priority: (change.priority ?? null) as never,
addLabels: change.addLabels ?? null,
},

View File

@@ -1676,6 +1676,7 @@ export type PublishReleaseMutation = {
export type ArchiveReleaseMutationVariables = Exact<{
releaseId: string;
moveTo?: string | null | undefined;
}>;
export type ArchiveReleaseMutation = {
@@ -10488,6 +10489,14 @@ export const ArchiveReleaseDocument = {
type: { kind: 'NamedType', name: { kind: 'Name', value: 'ID' } },
},
},
{
kind: 'VariableDefinition',
variable: {
kind: 'Variable',
name: { kind: 'Name', value: 'moveTo' },
},
type: { kind: 'NamedType', name: { kind: 'Name', value: 'ID' } },
},
],
selectionSet: {
kind: 'SelectionSet',
@@ -10504,6 +10513,14 @@ export const ArchiveReleaseDocument = {
name: { kind: 'Name', value: 'releaseId' },
},
},
{
kind: 'Argument',
name: { kind: 'Name', value: 'moveTo' },
value: {
kind: 'Variable',
name: { kind: 'Name', value: 'moveTo' },
},
},
],
selectionSet: {
kind: 'SelectionSet',

View File

@@ -24,8 +24,8 @@ mutation PublishRelease($releaseId: ID!, $force: Boolean!) {
}
}
mutation ArchiveRelease($releaseId: ID!) {
archiveRelease(releaseId: $releaseId) {
mutation ArchiveRelease($releaseId: ID!, $moveTo: ID) {
archiveRelease(releaseId: $releaseId, moveTo: $moveTo) {
...CommandResultFields
}
}

View File

@@ -4,6 +4,7 @@ import {
computed,
effect,
inject,
input,
signal,
untracked,
} from '@angular/core';
@@ -105,6 +106,10 @@ const numberOf = (key: string) => Number(key.split('-').pop());
[options]="assigneeFilterOptions()"
[(value)]="assignee"
/>
<pi-select-field
[options]="releaseFilterOptions()"
[(value)]="releaseFilter"
/>
@if (facade.loaded()) {
<span class="ts-muted ts-mono ts-list__count">
{{ facade.total() }} tickets{{
@@ -430,6 +435,8 @@ const numberOf = (key: string) => Number(key.split('-').pop());
`,
})
export class ListPage {
/** `?release=<id>`: opens already filtered by that release. */
readonly release = input<string | undefined>(undefined);
protected readonly facade = inject(ListFacade);
protected readonly project = inject(ProjectFacade);
protected readonly NOBODY = NOBODY;
@@ -455,6 +462,11 @@ export class ListPage {
protected readonly type = signal<string | null>(ALL);
protected readonly priority = signal<string | null>(ALL);
protected readonly assignee = signal<string | null>(ALL);
protected readonly releaseFilter = signal<string | null>(ALL);
private readonly releaseFromUrl = effect(() => {
const id = this.release();
if (id) untracked(() => this.releaseFilter.set(id));
});
protected readonly scopeOptions: PiSelectFieldOption<string>[] = [
{ value: 'open', label: 'Abiertos' },
{ value: 'closed', label: 'Terminados o cancelados' },
@@ -483,6 +495,24 @@ export class ListPage {
.members()
.map((m) => ({ value: m.user.id, label: m.user.name })),
]);
/** Planned ones first: those are the ones tickets move between. */
protected readonly releaseFilterOptions = computed<
PiSelectFieldOption<string>[]
>(() => [
{ value: ALL, label: 'Todas las releases' },
...[...this.project.releases()]
.sort(
(a, b) =>
Number(b.status === 'PLANNED') - Number(a.status === 'PLANNED'),
)
.map((r) => ({
value: r.id,
label:
r.status === 'PLANNED'
? r.name
: `${r.name} (${r.status === 'RELEASED' ? 'publicada' : 'archivada'})`,
})),
]);
private readonly criteria = computed<ListCriteria>(() => {
const scope = this.scope();
const assignee = this.assignee();
@@ -496,6 +526,7 @@ export class ListPage {
: undefined,
unassigned: assignee === NOBODY || undefined,
assigneeId: assignee && assignee !== NOBODY ? assignee : undefined,
releaseId: this.releaseFilter() || undefined,
};
});

View File

@@ -14,6 +14,8 @@ import {
PiActionButtonComponent,
PiEmptyStateComponent,
PiLabelDirective,
PiSelectFieldComponent,
PiSelectFieldOption,
PiTextareaComponent,
PiTextFieldComponent,
} from '@pi/ui';
@@ -23,6 +25,9 @@ import { ProjectFacade } from '@task-stream/web/tracker/application';
import { Release, ReleaseStatus } from '@task-stream/web/tracker/domain';
import { RELEASE_STATUS, releaseWhen } from '../ui/copy';
/** The «no release» choice of a select (an empty value). */
const NO_RELEASE = '';
@Component({
selector: 'ts-releases-page',
changeDetection: ChangeDetectionStrategy.OnPush,
@@ -33,6 +38,7 @@ import { RELEASE_STATUS, releaseWhen } from '../ui/copy';
PiActionButtonComponent,
PiEmptyStateComponent,
PiLabelDirective,
PiSelectFieldComponent,
PiTextFieldComponent,
PiTextareaComponent,
],
@@ -103,6 +109,12 @@ import { RELEASE_STATUS, releaseWhen } from '../ui/copy';
[queryParams]="{ release: release.id }"
>Ver en el tablero →</a
>
<a
class="ts-release__link"
[routerLink]="['../list']"
[queryParams]="{ release: release.id }"
>Ver en la lista →</a
>
<span class="ts-spacer"></span>
@if (release.notes) {
<pi-action-button
@@ -179,6 +191,28 @@ import { RELEASE_STATUS, releaseWhen } from '../ui/copy';
</form>
</ng-template>
<ng-template #archiveForm>
<form
class="ts-form"
(submit)="$event.preventDefault(); confirmArchive()"
novalidate
>
<p class="ts-muted">
{{ unfinished() }} ticket(s) sin terminar. Los terminados se quedan en
esta release como registro de lo hecho.
</p>
<pi-select-field [options]="moveToOptions()" [(value)]="moveTo">
<pi-label>Lo pendiente va a</pi-label>
</pi-select-field>
<pi-action-button
size="w100"
color="primary"
text="Archivar release"
(execute)="confirmArchive()"
/>
</form>
</ng-template>
<ng-template #createForm>
<form
class="ts-form"
@@ -353,6 +387,8 @@ export class ReleasesPage {
viewChild.required<TemplateRef<unknown>>('createForm');
private readonly notesForm =
viewChild.required<TemplateRef<unknown>>('notesForm');
private readonly archiveForm =
viewChild.required<TemplateRef<unknown>>('archiveForm');
private readonly toast = inject(ToastService);
private readonly destroyRef = inject(DestroyRef);
@@ -457,7 +493,44 @@ export class ReleasesPage {
.catch(() => this.toast.error('No se pudieron copiar'));
}
/* Archive: where its unfinished tickets go, as when closing a sprint. */
private archiving: Release | null = null;
protected readonly unfinished = signal(0);
protected readonly moveTo = signal<string | null>(NO_RELEASE);
protected readonly moveToOptions = signal<PiSelectFieldOption<string>[]>([]);
protected archive(release: Release): void {
this.facade.archiveRelease(release.id);
this.archiving = release;
// The next one first: the nearest target date, then the oldest planned
// (the list comes newest first).
const open = this.facade
.releases()
.filter((r) => r.status === ReleaseStatus.PLANNED && r.id !== release.id)
.map((r, i) => ({ r, i }))
.sort(
(a, b) =>
(a.r.targetDate ?? '9999').localeCompare(b.r.targetDate ?? '9999') ||
b.i - a.i,
)
.map(({ r }) => r);
this.unfinished.set(release.progress.total - release.progress.finished);
this.moveToOptions.set([
{ value: NO_RELEASE, label: 'Ninguna release' },
...open.map((r) => ({ value: r.id, label: r.name })),
]);
this.moveTo.set(open[0]?.id ?? NO_RELEASE);
// Nothing to place: no need to ask.
if (this.unfinished() === 0) {
this.confirmArchive();
return;
}
this.drawer.open(`Archivar ${release.name}`, this.archiveForm());
}
protected confirmArchive(): void {
if (!this.archiving) return;
this.facade.archiveRelease(this.archiving.id, this.moveTo() || null);
this.archiving = null;
this.drawer.closeAll();
}
}

View File

@@ -15,7 +15,11 @@ import {
type PiSelectFieldOption,
} from '@pi/ui';
import { ProjectFacade } from '@task-stream/web/tracker/application';
import { BulkChange, IssuePriority } from '@task-stream/web/tracker/domain';
import {
BulkChange,
IssuePriority,
ReleaseStatus,
} from '@task-stream/web/tracker/domain';
import { options, PRIORITY } from './copy';
/** "Nobody" / "no sprint" in the selects. */
@@ -62,6 +66,9 @@ const NOBODY = '__none__';
@case ('sprint') {
<pi-select-field [options]="sprintOptions()" [(value)]="value" />
}
@case ('release') {
<pi-select-field [options]="releaseOptions()" [(value)]="value" />
}
@case ('priority') {
<pi-select-field [options]="priorityOptions" [(value)]="value" />
}
@@ -112,6 +119,7 @@ export class BulkBarComponent {
{ value: 'status', label: 'Mover a…' },
{ value: 'assignee', label: 'Asignar a…' },
{ value: 'sprint', label: 'Sprint…' },
{ value: 'release', label: 'Release…' },
{ value: 'priority', label: 'Prioridad…' },
{ value: 'label', label: 'Añadir etiqueta…' },
];
@@ -141,6 +149,16 @@ export class BulkBarComponent {
.map((s) => ({ value: s.id, label: s.name })),
],
);
/** Only open releases take tickets. */
protected readonly releaseOptions = computed<PiSelectFieldOption<string>[]>(
() => [
{ value: NOBODY, label: 'Ninguna release' },
...this.project
.releases()
.filter((r) => r.status === ReleaseStatus.PLANNED)
.map((r) => ({ value: r.id, label: r.name })),
],
);
protected readonly priorityOptions = options(PRIORITY);
protected readonly canApply = computed(() =>
this.field() === 'label' ? !!this.label().trim() : !!this.value(),
@@ -159,6 +177,9 @@ export class BulkBarComponent {
case 'sprint':
change.sprintId = value === NOBODY ? null : value;
break;
case 'release':
change.releaseId = value === NOBODY ? null : value;
break;
case 'priority':
if (value) change.priority = value as IssuePriority;
break;