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:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user