feat: epics, subtasks and checklists
Tickets can hang from another one of the same project, three levels deep at most (epic → ticket → subtask). The ticket page lists its subtasks with their progress, creates new ones and moves the ticket under an epic; a new Épicas tab shows every epic and how far it has got. Each ticket also gets a checklist for the small steps that don't deserve a ticket. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,72 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { CommandHandler, ICommandHandler } from '@nestjs/cqrs';
|
||||
import {
|
||||
AddChecklistItemCommand,
|
||||
RemoveChecklistItemCommand,
|
||||
UpdateChecklistItemCommand,
|
||||
} from './change-checklist.command';
|
||||
import { CommandResult, ack } from './command-result';
|
||||
import { WriteAccess } from './write-access';
|
||||
|
||||
type ChecklistCommand =
|
||||
| AddChecklistItemCommand
|
||||
| UpdateChecklistItemCommand
|
||||
| RemoveChecklistItemCommand;
|
||||
|
||||
/** Every change to a ticket's checklist: whoever can edit the ticket may. */
|
||||
@CommandHandler(AddChecklistItemCommand)
|
||||
export class AddChecklistItemCommandHandler
|
||||
implements ICommandHandler<AddChecklistItemCommand>
|
||||
{
|
||||
constructor(private readonly access: WriteAccess) {}
|
||||
|
||||
execute(command: AddChecklistItemCommand): Promise<CommandResult> {
|
||||
return change(this.access, command, (issue) =>
|
||||
issue.addChecklistItem(command.actorId, randomUUID(), command.text),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@CommandHandler(UpdateChecklistItemCommand)
|
||||
export class UpdateChecklistItemCommandHandler
|
||||
implements ICommandHandler<UpdateChecklistItemCommand>
|
||||
{
|
||||
constructor(private readonly access: WriteAccess) {}
|
||||
|
||||
execute(command: UpdateChecklistItemCommand): Promise<CommandResult> {
|
||||
return change(this.access, command, (issue) =>
|
||||
issue.updateChecklistItem(
|
||||
command.actorId,
|
||||
command.itemId,
|
||||
command.changes,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@CommandHandler(RemoveChecklistItemCommand)
|
||||
export class RemoveChecklistItemCommandHandler
|
||||
implements ICommandHandler<RemoveChecklistItemCommand>
|
||||
{
|
||||
constructor(private readonly access: WriteAccess) {}
|
||||
|
||||
execute(command: RemoveChecklistItemCommand): Promise<CommandResult> {
|
||||
return change(this.access, command, (issue) =>
|
||||
issue.removeChecklistItem(command.actorId, command.itemId),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function change(
|
||||
access: WriteAccess,
|
||||
command: ChecklistCommand,
|
||||
apply: (issue: Awaited<ReturnType<WriteAccess['issue']>>) => void,
|
||||
): Promise<CommandResult> {
|
||||
const { issue } = await access.issueForContributor(
|
||||
command.issueId,
|
||||
command.actorId,
|
||||
);
|
||||
apply(issue);
|
||||
await issue.commitAndWait();
|
||||
return ack(issue);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { Command } from '@nestjs/cqrs';
|
||||
import { CommandResult } from './command-result';
|
||||
|
||||
export class AddChecklistItemCommand extends Command<CommandResult> {
|
||||
constructor(
|
||||
public readonly actorId: string,
|
||||
public readonly issueId: string,
|
||||
public readonly text: string,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
|
||||
/** Rewords, ticks or unticks a checklist step. */
|
||||
export class UpdateChecklistItemCommand extends Command<CommandResult> {
|
||||
constructor(
|
||||
public readonly actorId: string,
|
||||
public readonly issueId: string,
|
||||
public readonly itemId: string,
|
||||
public readonly changes: { text?: string; done?: boolean },
|
||||
) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
|
||||
export class RemoveChecklistItemCommand extends Command<CommandResult> {
|
||||
constructor(
|
||||
public readonly actorId: string,
|
||||
public readonly issueId: string,
|
||||
public readonly itemId: string,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,13 @@ import { UnassignIssueCommandHandler } from './unassign-issue.command-handler';
|
||||
import { AttachFileCommandHandler } from './attach-file.command-handler';
|
||||
import { DetachFileCommandHandler } from './detach-file.command-handler';
|
||||
import { WatchIssueCommandHandler } from './watch-issue.command-handler';
|
||||
import { SetIssueParentCommandHandler } from './set-issue-parent.command-handler';
|
||||
import { DetachFromParentCommandHandler } from './detach-from-parent.command-handler';
|
||||
import {
|
||||
AddChecklistItemCommandHandler,
|
||||
RemoveChecklistItemCommandHandler,
|
||||
UpdateChecklistItemCommandHandler,
|
||||
} from './change-checklist.command-handler';
|
||||
|
||||
export const COMMAND_HANDLERS = [
|
||||
CreateProjectCommandHandler,
|
||||
@@ -61,4 +68,10 @@ export const COMMAND_HANDLERS = [
|
||||
AttachFileCommandHandler,
|
||||
DetachFileCommandHandler,
|
||||
WatchIssueCommandHandler,
|
||||
SetIssueParentCommandHandler,
|
||||
AddChecklistItemCommandHandler,
|
||||
UpdateChecklistItemCommandHandler,
|
||||
RemoveChecklistItemCommandHandler,
|
||||
// Saga only: the parent was deleted.
|
||||
DetachFromParentCommandHandler,
|
||||
];
|
||||
|
||||
@@ -28,7 +28,15 @@ import {
|
||||
InMemoryProjectKeys,
|
||||
InMemoryReleaseNames,
|
||||
InMemoryReleaseIssues,
|
||||
InMemoryTrackerStreams,
|
||||
} from '../../testing/in-memory';
|
||||
import { TrackerStreamsRepository } from '../ports/tracker-streams.repository';
|
||||
import { IssueTree } from './issue-tree';
|
||||
import { SetIssueParentCommand } from './set-issue-parent.command';
|
||||
import {
|
||||
AddChecklistItemCommand,
|
||||
UpdateChecklistItemCommand,
|
||||
} from './change-checklist.command';
|
||||
import { ReleaseIssuesRepository } from '../ports/release-issues.repository';
|
||||
import { IssueNumbersRepository } from '../ports/issue-numbers.repository';
|
||||
import { ProjectKeysRepository } from '../ports/project-keys.repository';
|
||||
@@ -67,6 +75,7 @@ async function setup() {
|
||||
...COMMAND_HANDLERS,
|
||||
...TRACKER_FACTORIES,
|
||||
WriteAccess,
|
||||
IssueTree,
|
||||
AggregateRehydrator,
|
||||
{ provide: EventStore, useValue: store },
|
||||
{ provide: AttachmentStorage, useValue: files },
|
||||
@@ -78,6 +87,10 @@ async function setup() {
|
||||
provide: ReleaseIssuesRepository,
|
||||
useValue: new InMemoryReleaseIssues(store),
|
||||
},
|
||||
{
|
||||
provide: TrackerStreamsRepository,
|
||||
useValue: new InMemoryTrackerStreams(store),
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
await moduleRef.init();
|
||||
@@ -281,6 +294,84 @@ describe('tracker commands (event sourced)', () => {
|
||||
).rejects.toThrow(ForbiddenError);
|
||||
});
|
||||
|
||||
describe('epics, subtasks and checklists', () => {
|
||||
const create = (
|
||||
run: (c: object) => Promise<CommandResult>,
|
||||
projectId: string,
|
||||
title: string,
|
||||
parentId?: string,
|
||||
) =>
|
||||
run(
|
||||
new CreateIssueCommand(
|
||||
'dev',
|
||||
projectId,
|
||||
IssueType.TASK,
|
||||
title,
|
||||
{},
|
||||
null,
|
||||
null,
|
||||
parentId,
|
||||
),
|
||||
);
|
||||
|
||||
it('nests tickets three levels deep at most, without loops', async () => {
|
||||
const { run, rehydrator } = await setup();
|
||||
const projectId = await projectWithTeam(run);
|
||||
const epic = await create(run, projectId, 'Checkout');
|
||||
const story = await create(run, projectId, 'Pay by card', epic.id);
|
||||
const subtask = await create(run, projectId, 'Stripe form', story.id);
|
||||
const loose = await create(run, projectId, 'Loose ticket');
|
||||
|
||||
expect((await rehydrator.rehydrate(subtask.id, Issue)).parentId).toBe(
|
||||
story.id,
|
||||
);
|
||||
// A fourth level, and a loop.
|
||||
await expect(
|
||||
create(run, projectId, 'Too deep', subtask.id),
|
||||
).rejects.toThrow(/at most 2 levels/);
|
||||
await expect(
|
||||
run(new SetIssueParentCommand('dev', epic.id, subtask.id)),
|
||||
).rejects.toThrow(BusinessRuleError);
|
||||
// A ticket with subtasks cannot go under one that has a parent.
|
||||
await run(new SetIssueParentCommand('dev', loose.id, epic.id));
|
||||
await expect(
|
||||
run(new SetIssueParentCommand('dev', story.id, loose.id)),
|
||||
).rejects.toThrow(/at most 2 levels/);
|
||||
// ...but it can move to another epic, and leave.
|
||||
await run(new SetIssueParentCommand('dev', loose.id, null));
|
||||
await run(new SetIssueParentCommand('dev', story.id, loose.id));
|
||||
expect((await rehydrator.rehydrate(story.id, Issue)).parentId).toBe(
|
||||
loose.id,
|
||||
);
|
||||
await expect(
|
||||
run(new SetIssueParentCommand('pm', story.id, null)),
|
||||
).rejects.toThrow(ForbiddenError);
|
||||
});
|
||||
|
||||
it('keeps a checklist on the ticket', async () => {
|
||||
const { run, rehydrator } = await setup();
|
||||
const projectId = await projectWithTeam(run);
|
||||
const issue = await create(run, projectId, 'Release 1.0');
|
||||
await run(new AddChecklistItemCommand('dev', issue.id, 'Changelog'));
|
||||
await run(new AddChecklistItemCommand('qa', issue.id, 'Smoke test'));
|
||||
const [first] = (await rehydrator.rehydrate(issue.id, Issue)).checklist;
|
||||
await run(
|
||||
new UpdateChecklistItemCommand('qa', issue.id, first.id, {
|
||||
done: true,
|
||||
}),
|
||||
);
|
||||
|
||||
const { checklist } = await rehydrator.rehydrate(issue.id, Issue);
|
||||
expect(checklist.map((i) => [i.text, i.done])).toEqual([
|
||||
['Changelog', true],
|
||||
['Smoke test', false],
|
||||
]);
|
||||
await expect(
|
||||
run(new AddChecklistItemCommand('pm', issue.id, 'Nope')),
|
||||
).rejects.toThrow(ForbiddenError);
|
||||
});
|
||||
});
|
||||
|
||||
it('does not let viewers modify tickets', async () => {
|
||||
const { run } = await setup();
|
||||
const projectId = await projectWithTeam(run);
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { CommandHandler, EventPublisher, ICommandHandler } from '@nestjs/cqrs';
|
||||
import { IssueFactory } from '@task-stream/api/tracker/domain';
|
||||
import { IssueFactory, assertCanNest } from '@task-stream/api/tracker/domain';
|
||||
import { IssueNumbersRepository } from '../ports/issue-numbers.repository';
|
||||
import { CommandResult, ack } from './command-result';
|
||||
import { CreateIssueCommand } from './create-issue.command';
|
||||
import { IssueTree } from './issue-tree';
|
||||
import { WriteAccess } from './write-access';
|
||||
|
||||
@CommandHandler(CreateIssueCommand)
|
||||
@@ -14,6 +15,7 @@ export class CreateIssueCommandHandler
|
||||
private readonly issueFactory: IssueFactory,
|
||||
private readonly issueNumbers: IssueNumbersRepository,
|
||||
private readonly eventPublisher: EventPublisher,
|
||||
private readonly tree: IssueTree,
|
||||
) {}
|
||||
|
||||
async execute(command: CreateIssueCommand): Promise<CommandResult> {
|
||||
@@ -27,6 +29,15 @@ export class CreateIssueCommandHandler
|
||||
this.access.assertAssignable(project, command.assigneeId);
|
||||
if (command.releaseId)
|
||||
await this.access.openReleaseOf(project, command.releaseId);
|
||||
const parent = command.parentId
|
||||
? await this.tree.candidate(command.parentId)
|
||||
: null;
|
||||
if (parent)
|
||||
assertCanNest(
|
||||
{ id: '', key: 'The new ticket', projectId: project.id },
|
||||
parent,
|
||||
0,
|
||||
);
|
||||
|
||||
const issue = this.issueFactory.create(
|
||||
{
|
||||
@@ -42,6 +53,7 @@ export class CreateIssueCommandHandler
|
||||
workflow,
|
||||
);
|
||||
if (command.releaseId) issue.setRelease(command.actorId, command.releaseId);
|
||||
if (parent) issue.setParent(command.actorId, parent);
|
||||
|
||||
this.eventPublisher.mergeObjectContext(issue);
|
||||
await issue.commitAndWait();
|
||||
|
||||
@@ -12,6 +12,8 @@ export class CreateIssueCommand extends Command<CommandResult> {
|
||||
public readonly details: Omit<IssueDetails, 'title' | 'type'>,
|
||||
public readonly assigneeId?: string | null,
|
||||
public readonly releaseId?: string | null,
|
||||
/** Epic or ticket the new one breaks down. */
|
||||
public readonly parentId?: string | null,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { CommandHandler, ICommandHandler } from '@nestjs/cqrs';
|
||||
import { Issue, Project } from '@task-stream/api/tracker/domain';
|
||||
import { DetachFromParentCommand } from './detach-from-parent.command';
|
||||
import { WriteAccess } from './write-access';
|
||||
|
||||
@CommandHandler(DetachFromParentCommand)
|
||||
export class DetachFromParentCommandHandler
|
||||
implements ICommandHandler<DetachFromParentCommand>
|
||||
{
|
||||
constructor(private readonly access: WriteAccess) {}
|
||||
|
||||
async execute(command: DetachFromParentCommand): Promise<void> {
|
||||
const issue = await this.access.live(command.issueId, Issue);
|
||||
// Gone (or its whole project is going), or it moved elsewhere already.
|
||||
if (!issue || issue.parentId !== command.parentId) return;
|
||||
if (!(await this.access.live(issue.projectId, Project))) return;
|
||||
issue.setParent(command.actorId, null);
|
||||
await issue.commitAndWait();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Command } from '@nestjs/cqrs';
|
||||
|
||||
/** Saga only: the parent of a ticket was deleted, so it goes back to the top. */
|
||||
export class DetachFromParentCommand extends Command<void> {
|
||||
constructor(
|
||||
public readonly actorId: string,
|
||||
public readonly issueId: string,
|
||||
public readonly parentId: string,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
@@ -29,3 +29,6 @@ export * from './unassign-issue.command';
|
||||
export * from './attach-file.command';
|
||||
export * from './detach-file.command';
|
||||
export * from './watch-issue.command';
|
||||
export * from './set-issue-parent.command';
|
||||
export * from './detach-from-parent.command';
|
||||
export * from './change-checklist.command';
|
||||
|
||||
60
libs/api/tracker/application/src/lib/commands/issue-tree.ts
Normal file
60
libs/api/tracker/application/src/lib/commands/issue-tree.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import {
|
||||
Issue,
|
||||
MAX_HIERARCHY_DEPTH,
|
||||
ParentCandidate,
|
||||
} from '@task-stream/api/tracker/domain';
|
||||
import { TrackerStreamsRepository } from '../ports/tracker-streams.repository';
|
||||
import { WriteAccess } from './write-access';
|
||||
|
||||
/**
|
||||
* Walks the epic → ticket → subtask tree on the write side (the event store),
|
||||
* so nesting rules never depend on a read model that may lag behind.
|
||||
*/
|
||||
@Injectable()
|
||||
export class IssueTree {
|
||||
constructor(
|
||||
private readonly access: WriteAccess,
|
||||
private readonly streams: TrackerStreamsRepository,
|
||||
) {}
|
||||
|
||||
/** `parentId` as a would-be parent: with its ancestors, closest first. */
|
||||
async candidate(parentId: string): Promise<ParentCandidate> {
|
||||
const parent = await this.access.issue(parentId);
|
||||
const ancestorIds: string[] = [];
|
||||
let next = parent.parentId;
|
||||
// One more than allowed is enough to tell a tree is too deep (or loops).
|
||||
while (next && ancestorIds.length <= MAX_HIERARCHY_DEPTH) {
|
||||
ancestorIds.push(next);
|
||||
next = (await this.access.live(next, Issue))?.parentId ?? null;
|
||||
}
|
||||
return {
|
||||
id: parent.id,
|
||||
key: parent.key,
|
||||
projectId: parent.projectId,
|
||||
ancestorIds,
|
||||
};
|
||||
}
|
||||
|
||||
/** Tickets hanging from `issueId` right now. */
|
||||
async childrenOf(issueId: string): Promise<Issue[]> {
|
||||
const everUnder = await this.streams.issueIdsEverUnder(issueId);
|
||||
const issues = await Promise.all(
|
||||
everUnder.map((id) => this.access.live(id, Issue)),
|
||||
);
|
||||
return issues.filter(
|
||||
(issue): issue is Issue => !!issue && issue.parentId === issueId,
|
||||
);
|
||||
}
|
||||
|
||||
/** Levels of tickets below `issueId` (0: none). */
|
||||
async heightOf(issueId: string, depth = 0): Promise<number> {
|
||||
if (depth > MAX_HIERARCHY_DEPTH) return 0;
|
||||
const children = await this.childrenOf(issueId);
|
||||
if (children.length === 0) return 0;
|
||||
const heights = await Promise.all(
|
||||
children.map((child) => this.heightOf(child.id, depth + 1)),
|
||||
);
|
||||
return 1 + Math.max(...heights);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { CommandHandler, ICommandHandler } from '@nestjs/cqrs';
|
||||
import { CommandResult, ack } from './command-result';
|
||||
import { IssueTree } from './issue-tree';
|
||||
import { SetIssueParentCommand } from './set-issue-parent.command';
|
||||
import { WriteAccess } from './write-access';
|
||||
|
||||
@CommandHandler(SetIssueParentCommand)
|
||||
export class SetIssueParentCommandHandler
|
||||
implements ICommandHandler<SetIssueParentCommand>
|
||||
{
|
||||
constructor(
|
||||
private readonly access: WriteAccess,
|
||||
private readonly tree: IssueTree,
|
||||
) {}
|
||||
|
||||
async execute(command: SetIssueParentCommand): Promise<CommandResult> {
|
||||
const { issue } = await this.access.issueForContributor(
|
||||
command.issueId,
|
||||
command.actorId,
|
||||
);
|
||||
if (command.parentId === null) {
|
||||
issue.setParent(command.actorId, null);
|
||||
} else if (command.parentId !== issue.parentId) {
|
||||
const [parent, height] = await Promise.all([
|
||||
this.tree.candidate(command.parentId),
|
||||
this.tree.heightOf(issue.id),
|
||||
]);
|
||||
issue.setParent(command.actorId, parent, height);
|
||||
}
|
||||
await issue.commitAndWait();
|
||||
return ack(issue);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Command } from '@nestjs/cqrs';
|
||||
import { CommandResult } from './command-result';
|
||||
|
||||
/** Places a ticket under an epic or another ticket (`parentId` null: out). */
|
||||
export class SetIssueParentCommand extends Command<CommandResult> {
|
||||
constructor(
|
||||
public readonly actorId: string,
|
||||
public readonly issueId: string,
|
||||
public readonly parentId: string | null,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,10 @@ import {
|
||||
IssueAssignedEvent,
|
||||
IssueAttachmentAddedEvent,
|
||||
IssueAttachmentRemovedEvent,
|
||||
IssueChecklistItemAddedEvent,
|
||||
IssueChecklistItemRemovedEvent,
|
||||
IssueChecklistItemUpdatedEvent,
|
||||
IssueParentChangedEvent,
|
||||
IssueCreatedEvent,
|
||||
IssueLinkedEvent,
|
||||
IssueLinkType,
|
||||
@@ -142,6 +146,23 @@ export function describeEvent(
|
||||
if (event instanceof IssueAttachmentRemovedEvent)
|
||||
return `${actor} quitó el adjunto ${event.fileName} de ${issueKey}`;
|
||||
|
||||
if (event instanceof IssueParentChangedEvent) {
|
||||
return event.parentKey
|
||||
? `${actor} colocó ${issueKey} bajo ${event.parentKey}`
|
||||
: `${actor} sacó ${issueKey} de su ticket padre`;
|
||||
}
|
||||
if (event instanceof IssueChecklistItemAddedEvent)
|
||||
return `${actor} añadió «${event.item.text}» a la lista de ${issueKey}`;
|
||||
if (event instanceof IssueChecklistItemUpdatedEvent) {
|
||||
if (event.changes.done === true)
|
||||
return `${actor} completó un paso de la lista de ${issueKey}`;
|
||||
if (event.changes.done === false)
|
||||
return `${actor} reabrió un paso de la lista de ${issueKey}`;
|
||||
return `${actor} cambió un paso de la lista de ${issueKey}`;
|
||||
}
|
||||
if (event instanceof IssueChecklistItemRemovedEvent)
|
||||
return `${actor} quitó «${event.text}» de la lista de ${issueKey}`;
|
||||
|
||||
if (event instanceof CommentAddedEvent)
|
||||
return `${actor} comentó en ${issueKey}`;
|
||||
if (event instanceof CommentEditedEvent)
|
||||
|
||||
@@ -7,6 +7,10 @@ import {
|
||||
IssueAssignedEvent,
|
||||
IssueAttachmentAddedEvent,
|
||||
IssueAttachmentRemovedEvent,
|
||||
IssueChecklistItemAddedEvent,
|
||||
IssueChecklistItemRemovedEvent,
|
||||
IssueChecklistItemUpdatedEvent,
|
||||
IssueParentChangedEvent,
|
||||
IssueCreatedEvent,
|
||||
IssueLinkedEvent,
|
||||
IssueReleaseChangedEvent,
|
||||
@@ -55,6 +59,10 @@ import { versionOf } from './read-model-notifier';
|
||||
IssueReleaseChangedEvent,
|
||||
IssueAttachmentAddedEvent,
|
||||
IssueAttachmentRemovedEvent,
|
||||
IssueParentChangedEvent,
|
||||
IssueChecklistItemAddedEvent,
|
||||
IssueChecklistItemUpdatedEvent,
|
||||
IssueChecklistItemRemovedEvent,
|
||||
CommentAddedEvent,
|
||||
CommentEditedEvent,
|
||||
CommentDeletedEvent,
|
||||
|
||||
@@ -28,6 +28,8 @@ import { IssueAttachmentAddedEventHandler } from './issue-attachment-added.event
|
||||
import { IssueAttachmentRemovedEventHandler } from './issue-attachment-removed.event-handler';
|
||||
import { AttachmentCleanupEventHandler } from './attachment-cleanup.event-handler';
|
||||
import { IssueWatchersEventHandler } from './issue-watchers.event-handler';
|
||||
import { IssueParentChangedEventHandler } from './issue-parent-changed.event-handler';
|
||||
import { IssueChecklistEventHandler } from './issue-checklist.event-handler';
|
||||
|
||||
/** Read-model projections (MongoDB) + realtime, fed by the EventsBridge. */
|
||||
export const EVENT_HANDLERS = [
|
||||
@@ -60,6 +62,8 @@ export const EVENT_HANDLERS = [
|
||||
IssueAttachmentRemovedEventHandler,
|
||||
AttachmentCleanupEventHandler,
|
||||
IssueWatchersEventHandler,
|
||||
IssueParentChangedEventHandler,
|
||||
IssueChecklistEventHandler,
|
||||
];
|
||||
|
||||
export { ReadModelNotifier };
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { EventsHandler, IEventHandler } from '@nestjs/cqrs';
|
||||
import {
|
||||
IssueChecklistItemAddedEvent,
|
||||
IssueChecklistItemRemovedEvent,
|
||||
IssueChecklistItemUpdatedEvent,
|
||||
} from '@task-stream/api/tracker/domain';
|
||||
import { UpsertMaterializedIssueRepository } from '../ports/upsert-materialized-issue.repository';
|
||||
import { ReadModelNotifier, versionOf } from './read-model-notifier';
|
||||
|
||||
type ChecklistEvent =
|
||||
| IssueChecklistItemAddedEvent
|
||||
| IssueChecklistItemUpdatedEvent
|
||||
| IssueChecklistItemRemovedEvent;
|
||||
|
||||
@EventsHandler(
|
||||
IssueChecklistItemAddedEvent,
|
||||
IssueChecklistItemUpdatedEvent,
|
||||
IssueChecklistItemRemovedEvent,
|
||||
)
|
||||
export class IssueChecklistEventHandler
|
||||
implements IEventHandler<ChecklistEvent>
|
||||
{
|
||||
constructor(
|
||||
private readonly upsertMaterializedIssueRepository: UpsertMaterializedIssueRepository,
|
||||
private readonly notifier: ReadModelNotifier,
|
||||
) {}
|
||||
|
||||
async handle(event: ChecklistEvent) {
|
||||
const repository = this.upsertMaterializedIssueRepository;
|
||||
await repository.upsert({
|
||||
id: event.aggregateId,
|
||||
updatedAt: event.occurredAt,
|
||||
version: versionOf(event),
|
||||
});
|
||||
if (event instanceof IssueChecklistItemAddedEvent) {
|
||||
await repository.addChecklistItem(event.aggregateId, event.item);
|
||||
} else if (event instanceof IssueChecklistItemUpdatedEvent) {
|
||||
await repository.updateChecklistItem(
|
||||
event.aggregateId,
|
||||
event.itemId,
|
||||
event.changes,
|
||||
);
|
||||
} else {
|
||||
await repository.removeChecklistItem(event.aggregateId, event.itemId);
|
||||
}
|
||||
await this.notifier.issue(event.aggregateId, event);
|
||||
}
|
||||
}
|
||||
@@ -48,6 +48,7 @@ export class IssueCreatedEventHandler
|
||||
reporter: users.of(issue.reporterId),
|
||||
assignee: users.ofNullable(issue.assigneeId),
|
||||
release: release ? { id: release.id, name: release.name } : null,
|
||||
parent: null,
|
||||
labels: issue.labels,
|
||||
figmaUrl: issue.figmaUrl,
|
||||
storyPoints: issue.storyPoints,
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { EventsHandler, IEventHandler } from '@nestjs/cqrs';
|
||||
import { IssueParentChangedEvent } from '@task-stream/api/tracker/domain';
|
||||
import { FindIssuesRepository } from '../ports/find-issues.repository';
|
||||
import { UpsertMaterializedIssueRepository } from '../ports/upsert-materialized-issue.repository';
|
||||
import { ReadModelNotifier, versionOf } from './read-model-notifier';
|
||||
|
||||
@EventsHandler(IssueParentChangedEvent)
|
||||
export class IssueParentChangedEventHandler
|
||||
implements IEventHandler<IssueParentChangedEvent>
|
||||
{
|
||||
constructor(
|
||||
private readonly upsertMaterializedIssueRepository: UpsertMaterializedIssueRepository,
|
||||
private readonly findIssues: FindIssuesRepository,
|
||||
private readonly notifier: ReadModelNotifier,
|
||||
) {}
|
||||
|
||||
async handle(event: IssueParentChangedEvent) {
|
||||
const parent = event.parentId
|
||||
? await this.findIssues.findById(event.parentId)
|
||||
: null;
|
||||
await this.upsertMaterializedIssueRepository.upsert({
|
||||
id: event.aggregateId,
|
||||
parent: event.parentId
|
||||
? {
|
||||
id: event.parentId,
|
||||
key: parent?.key ?? event.parentKey ?? '',
|
||||
title: parent?.title ?? '',
|
||||
}
|
||||
: null,
|
||||
updatedAt: event.occurredAt,
|
||||
version: versionOf(event),
|
||||
});
|
||||
await this.notifier.issue(event.aggregateId, event);
|
||||
// Both parents list their subtasks: they changed too.
|
||||
for (const id of [event.parentId, event.previousParentId]) {
|
||||
if (id) await this.notifier.issue(id, event);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,12 @@ export class IssueUpdatedEventHandler
|
||||
updatedAt: event.occurredAt,
|
||||
version: versionOf(event),
|
||||
});
|
||||
// Its subtasks show its title.
|
||||
if (event.changes.title !== undefined)
|
||||
await this.upsertMaterializedIssueRepository.renameParent(
|
||||
event.aggregateId,
|
||||
event.changes.title,
|
||||
);
|
||||
await this.notifier.issue(event.aggregateId, event);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Injectable } from '@nestjs/common';
|
||||
import { CommandBus, QueryBus } from '@nestjs/cqrs';
|
||||
import {
|
||||
ActivityReadModel,
|
||||
EpicReadModel,
|
||||
IssueDetails,
|
||||
IssueLinkType,
|
||||
IssueReadModel,
|
||||
@@ -13,6 +14,12 @@ import {
|
||||
} from './commands/attach-file.command';
|
||||
import { DetachFileCommand } from './commands/detach-file.command';
|
||||
import { WatchIssueCommand } from './commands/watch-issue.command';
|
||||
import { SetIssueParentCommand } from './commands/set-issue-parent.command';
|
||||
import {
|
||||
AddChecklistItemCommand,
|
||||
RemoveChecklistItemCommand,
|
||||
UpdateChecklistItemCommand,
|
||||
} from './commands/change-checklist.command';
|
||||
import { ChangeIssueStatusCommand } from './commands/change-issue-status.command';
|
||||
import { CommandResult } from './commands/command-result';
|
||||
import { CreateIssueCommand } from './commands/create-issue.command';
|
||||
@@ -29,6 +36,7 @@ import {
|
||||
import { GetIssueQuery } from './queries/get-issue.query';
|
||||
import { GetIssuesByIdsQuery } from './queries/get-issues-by-ids.query';
|
||||
import { SearchIssuesQuery } from './queries/search-issues.query';
|
||||
import { GetEpicsQuery } from './queries/get-epics.query';
|
||||
import {
|
||||
MyIssuesFilter,
|
||||
SearchMyIssuesQuery,
|
||||
@@ -41,6 +49,8 @@ export interface CreateIssueInput extends Omit<IssueDetails, 'title' | 'type'> {
|
||||
title: string;
|
||||
assigneeId?: string | null;
|
||||
releaseId?: string | null;
|
||||
/** Epic or ticket the new one breaks down. */
|
||||
parentId?: string | null;
|
||||
}
|
||||
|
||||
export interface IssueLinkInput {
|
||||
@@ -57,7 +67,15 @@ export class IssuesService {
|
||||
) {}
|
||||
|
||||
create(actorId: string, input: CreateIssueInput): Promise<CommandResult> {
|
||||
const { projectId, type, title, assigneeId, releaseId, ...details } = input;
|
||||
const {
|
||||
projectId,
|
||||
type,
|
||||
title,
|
||||
assigneeId,
|
||||
releaseId,
|
||||
parentId,
|
||||
...details
|
||||
} = input;
|
||||
return this.commandBus.execute(
|
||||
new CreateIssueCommand(
|
||||
actorId,
|
||||
@@ -67,6 +85,7 @@ export class IssuesService {
|
||||
details,
|
||||
assigneeId,
|
||||
releaseId,
|
||||
parentId,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -188,6 +207,52 @@ export class IssuesService {
|
||||
return this.queryBus.execute(new GetIssuesByIdsQuery(viewerId, ids));
|
||||
}
|
||||
|
||||
/** Places the ticket under `parentId` (null: back to the top). */
|
||||
setParent(
|
||||
actorId: string,
|
||||
issueId: string,
|
||||
parentId: string | null,
|
||||
): Promise<CommandResult> {
|
||||
return this.commandBus.execute(
|
||||
new SetIssueParentCommand(actorId, issueId, parentId),
|
||||
);
|
||||
}
|
||||
|
||||
addChecklistItem(
|
||||
actorId: string,
|
||||
issueId: string,
|
||||
text: string,
|
||||
): Promise<CommandResult> {
|
||||
return this.commandBus.execute(
|
||||
new AddChecklistItemCommand(actorId, issueId, text),
|
||||
);
|
||||
}
|
||||
|
||||
updateChecklistItem(
|
||||
actorId: string,
|
||||
issueId: string,
|
||||
itemId: string,
|
||||
changes: { text?: string; done?: boolean },
|
||||
): Promise<CommandResult> {
|
||||
return this.commandBus.execute(
|
||||
new UpdateChecklistItemCommand(actorId, issueId, itemId, changes),
|
||||
);
|
||||
}
|
||||
|
||||
removeChecklistItem(
|
||||
actorId: string,
|
||||
issueId: string,
|
||||
itemId: string,
|
||||
): Promise<CommandResult> {
|
||||
return this.commandBus.execute(
|
||||
new RemoveChecklistItemCommand(actorId, issueId, itemId),
|
||||
);
|
||||
}
|
||||
|
||||
epics(viewerId: string | null, projectId: string): Promise<EpicReadModel[]> {
|
||||
return this.queryBus.execute(new GetEpicsQuery(viewerId, projectId));
|
||||
}
|
||||
|
||||
search(
|
||||
viewerId: string | null,
|
||||
filter: IssueFilter,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
EpicReadModel,
|
||||
IssuePriority,
|
||||
IssueReadModel,
|
||||
ProjectStatsReadModel,
|
||||
@@ -18,6 +19,8 @@ export interface IssueFilter {
|
||||
unassigned?: boolean;
|
||||
releaseId?: string;
|
||||
labels?: string[];
|
||||
/** Only the tickets hanging from this one. */
|
||||
parentId?: string;
|
||||
search?: string;
|
||||
limit: number;
|
||||
offset: number;
|
||||
@@ -51,6 +54,8 @@ export abstract class FindIssuesRepository {
|
||||
abstract findOpenAssignedTo(userId: string): Promise<IssueReadModel[]>;
|
||||
/** Every ticket of a project (statistics). */
|
||||
abstract findByProject(projectId: string): Promise<IssueReadModel[]>;
|
||||
/** Top-level tickets with others hanging from them, by key. */
|
||||
abstract epics(projectId: string): Promise<EpicReadModel[]>;
|
||||
abstract releaseProgress(
|
||||
releaseId: string,
|
||||
): Promise<ReleaseProgressReadModel>;
|
||||
|
||||
@@ -10,4 +10,6 @@ export abstract class TrackerStreamsRepository {
|
||||
abstract commentIdsOfIssue(issueId: string): Promise<string[]>;
|
||||
/** Tickets that were ever linked to `issueId` (they may have unlinked since). */
|
||||
abstract issueIdsLinkingTo(issueId: string): Promise<string[]>;
|
||||
/** Tickets that were ever placed under `issueId` (they may have left since). */
|
||||
abstract issueIdsEverUnder(issueId: string): Promise<string[]>;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
AttachmentReadModel,
|
||||
ChecklistItemReadModel,
|
||||
IssueLinkReadModel,
|
||||
IssueReadModel,
|
||||
StatusCategory,
|
||||
@@ -13,7 +14,7 @@ export abstract class UpsertMaterializedIssueRepository {
|
||||
Partial<
|
||||
Omit<
|
||||
IssueReadModel,
|
||||
'links' | 'commentCount' | 'attachments' | 'watchers'
|
||||
'links' | 'commentCount' | 'attachments' | 'watchers' | 'checklist'
|
||||
>
|
||||
>,
|
||||
): Promise<void>;
|
||||
@@ -35,6 +36,19 @@ export abstract class UpsertMaterializedIssueRepository {
|
||||
/** Adds a follower (idempotent). */
|
||||
abstract addWatcher(issueId: string, user: UserRefReadModel): Promise<void>;
|
||||
abstract removeWatcher(issueId: string, userId: string): Promise<void>;
|
||||
/** Adds a checklist step (idempotent). */
|
||||
abstract addChecklistItem(
|
||||
issueId: string,
|
||||
item: ChecklistItemReadModel,
|
||||
): Promise<void>;
|
||||
abstract updateChecklistItem(
|
||||
issueId: string,
|
||||
itemId: string,
|
||||
changes: Partial<Pick<ChecklistItemReadModel, 'text' | 'done'>>,
|
||||
): Promise<void>;
|
||||
abstract removeChecklistItem(issueId: string, itemId: string): Promise<void>;
|
||||
/** Keeps the denormalised parent title of a ticket's children in sync. */
|
||||
abstract renameParent(parentId: string, title: string): Promise<void>;
|
||||
abstract setCommentCount(
|
||||
issueId: string,
|
||||
commentCount: number,
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { IQueryHandler, QueryHandler } from '@nestjs/cqrs';
|
||||
import { EpicReadModel } from '@task-stream/api/tracker/domain';
|
||||
import { FindIssuesRepository } from '../ports/find-issues.repository';
|
||||
import { GetEpicsQuery } from './get-epics.query';
|
||||
import { ReadAccess } from './read-access';
|
||||
|
||||
@QueryHandler(GetEpicsQuery)
|
||||
export class GetEpicsQueryHandler
|
||||
implements IQueryHandler<GetEpicsQuery, EpicReadModel[]>
|
||||
{
|
||||
constructor(
|
||||
private readonly findIssues: FindIssuesRepository,
|
||||
private readonly access: ReadAccess,
|
||||
) {}
|
||||
|
||||
async execute(query: GetEpicsQuery): Promise<EpicReadModel[]> {
|
||||
await this.access.assertCanView(query.projectId, query.viewerId);
|
||||
return this.findIssues.epics(query.projectId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Query } from '@nestjs/cqrs';
|
||||
import { EpicReadModel } from '@task-stream/api/tracker/domain';
|
||||
|
||||
/** A project's epics: top-level tickets that group others, with progress. */
|
||||
export class GetEpicsQuery extends Query<EpicReadModel[]> {
|
||||
constructor(
|
||||
/** Null: anonymous (public projects only). */
|
||||
public readonly viewerId: string | null,
|
||||
public readonly projectId: string,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
@@ -17,3 +17,4 @@ export * from './search-issues.query';
|
||||
export * from './search-my-issues.query';
|
||||
export * from './get-public-projects.query';
|
||||
export * from './get-attachment.query';
|
||||
export * from './get-epics.query';
|
||||
|
||||
@@ -16,6 +16,7 @@ import { GetReleaseProgressQueryHandler } from './get-release-progress.query-han
|
||||
import { GetReleasesQueryHandler } from './get-releases.query-handler';
|
||||
import { GetUpcomingReleasesQueryHandler } from './get-upcoming-releases.query-handler';
|
||||
import { SearchIssuesQueryHandler } from './search-issues.query-handler';
|
||||
import { GetEpicsQueryHandler } from './get-epics.query-handler';
|
||||
import { SearchMyIssuesQueryHandler } from './search-my-issues.query-handler';
|
||||
|
||||
export const QUERY_HANDLERS = [
|
||||
@@ -36,6 +37,7 @@ export const QUERY_HANDLERS = [
|
||||
GetReleasesQueryHandler,
|
||||
GetUpcomingReleasesQueryHandler,
|
||||
SearchIssuesQueryHandler,
|
||||
GetEpicsQueryHandler,
|
||||
SearchMyIssuesQueryHandler,
|
||||
GetAttachmentQueryHandler,
|
||||
];
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
import { from, mergeMap, Observable } from 'rxjs';
|
||||
import { DeleteIssueCommand } from '../commands/delete-issue.command';
|
||||
import { DeleteReleaseCommand } from '../commands/delete-release.command';
|
||||
import { DetachFromParentCommand } from '../commands/detach-from-parent.command';
|
||||
import { DropLinksToIssueCommand } from '../commands/drop-links-to-issue.command';
|
||||
import { PurgeCommentCommand } from '../commands/purge-comment.command';
|
||||
import { TrackerStreamsRepository } from '../ports/tracker-streams.repository';
|
||||
@@ -17,7 +18,8 @@ import { TrackerStreamsRepository } from '../ports/tracker-streams.repository';
|
||||
*
|
||||
* ProjectDeleted → DeleteIssue (each ticket) + DeleteRelease (each release)
|
||||
* IssueDeleted → PurgeComment (each comment) + DropLinksToIssue (tickets
|
||||
* of other projects that pointed to it)
|
||||
* of other projects that pointed to it) + DetachFromParent
|
||||
* (tickets that hung from it go back to the top)
|
||||
*
|
||||
* Who to delete comes from the event store, not the read model. The changes
|
||||
* are attributed to whoever deleted the project.
|
||||
@@ -48,7 +50,7 @@ export class ProjectDeletionSaga {
|
||||
ofType(IssueDeletedEvent),
|
||||
mergeMap((event) =>
|
||||
from(this.dependentsOf(event.aggregateId)).pipe(
|
||||
mergeMap(({ commentIds, linkingIds }) => [
|
||||
mergeMap(({ commentIds, linkingIds, childIds }) => [
|
||||
...commentIds.map(
|
||||
(id) => new PurgeCommentCommand(event.actorId, id),
|
||||
),
|
||||
@@ -60,6 +62,14 @@ export class ProjectDeletionSaga {
|
||||
event.aggregateId,
|
||||
),
|
||||
),
|
||||
...childIds.map(
|
||||
(id) =>
|
||||
new DetachFromParentCommand(
|
||||
event.actorId,
|
||||
id,
|
||||
event.aggregateId,
|
||||
),
|
||||
),
|
||||
]),
|
||||
),
|
||||
),
|
||||
@@ -74,13 +84,15 @@ export class ProjectDeletionSaga {
|
||||
}
|
||||
|
||||
private async dependentsOf(issueId: string) {
|
||||
const [commentIds, linkingIds] = await Promise.all([
|
||||
const [commentIds, linkingIds, childIds] = await Promise.all([
|
||||
this.streams.commentIdsOfIssue(issueId),
|
||||
this.streams.issueIdsLinkingTo(issueId),
|
||||
this.streams.issueIdsEverUnder(issueId),
|
||||
]);
|
||||
return {
|
||||
commentIds,
|
||||
linkingIds: linkingIds.filter((id) => id !== issueId),
|
||||
childIds,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,12 +39,14 @@ import { ChangeIssueStatusCommand } from '../commands/change-issue-status.comman
|
||||
import { COMMAND_HANDLERS } from '../commands/command-handlers';
|
||||
import { CommandResult } from '../commands/command-result';
|
||||
import { CreateIssueCommand } from '../commands/create-issue.command';
|
||||
import { DeleteIssueCommand } from '../commands/delete-issue.command';
|
||||
import { CreateProjectCommand } from '../commands/create-project.command';
|
||||
import { CreateReleaseCommand } from '../commands/create-release.command';
|
||||
import { DeleteProjectCommand } from '../commands/delete-project.command';
|
||||
import { LinkIssuesCommand } from '../commands/link-issues.command';
|
||||
import { AssignIssueCommand } from '../commands/assign-issue.command';
|
||||
import { WriteAccess } from '../commands/write-access';
|
||||
import { IssueTree } from '../commands/issue-tree';
|
||||
import { FindIssuesRepository } from '../ports/find-issues.repository';
|
||||
import { IssueNumbersRepository } from '../ports/issue-numbers.repository';
|
||||
import { ProjectKeysRepository } from '../ports/project-keys.repository';
|
||||
@@ -72,6 +74,7 @@ async function setup() {
|
||||
ReleaseArchivedSaga,
|
||||
UserDeactivatedSaga,
|
||||
WriteAccess,
|
||||
IssueTree,
|
||||
AggregateRehydrator,
|
||||
{ provide: EventStore, useValue: store },
|
||||
{ provide: AttachmentStorage, useValue: files },
|
||||
@@ -178,6 +181,31 @@ describe('sagas', () => {
|
||||
).resolves.toMatchObject({ key: 'WEB' });
|
||||
});
|
||||
|
||||
it('a deleted ticket lets the tickets under it go back to the top', async () => {
|
||||
const { run, drain, rehydrator } = await setup();
|
||||
const web = (await run(new CreateProjectCommand('owner', 'WEB', 'Web'))).id;
|
||||
const epic = (await run(ticket(web, 'Epic'))).id;
|
||||
const child = (
|
||||
await run(
|
||||
new CreateIssueCommand(
|
||||
'owner',
|
||||
web,
|
||||
'TASK',
|
||||
'child',
|
||||
{},
|
||||
null,
|
||||
null,
|
||||
epic,
|
||||
),
|
||||
)
|
||||
).id;
|
||||
|
||||
await run(new DeleteIssueCommand('owner', epic));
|
||||
await drain();
|
||||
|
||||
expect((await rehydrator.rehydrate(child, Issue)).parentId).toBeNull();
|
||||
});
|
||||
|
||||
it('only the owner deletes a project', async () => {
|
||||
const { run } = await setup();
|
||||
const web = (await run(new CreateProjectCommand('owner', 'WEB', 'Web'))).id;
|
||||
|
||||
@@ -4,6 +4,7 @@ import { TRACKER_FACTORIES } from '@task-stream/api/tracker/domain';
|
||||
import { ChangesService } from './changes.service';
|
||||
import { COMMAND_HANDLERS } from './commands/command-handlers';
|
||||
import { WriteAccess } from './commands/write-access';
|
||||
import { IssueTree } from './commands/issue-tree';
|
||||
import { CommentsService } from './comments.service';
|
||||
import {
|
||||
EVENT_HANDLERS,
|
||||
@@ -59,6 +60,7 @@ const SERVICES = [
|
||||
...SERVICES,
|
||||
...TRACKER_FACTORIES,
|
||||
WriteAccess,
|
||||
IssueTree,
|
||||
ReadAccess,
|
||||
ReadModelNotifier,
|
||||
...COMMAND_HANDLERS,
|
||||
|
||||
@@ -256,6 +256,13 @@ export class InMemoryTrackerStreams extends TrackerStreamsRepository {
|
||||
(d) => (d['link'] as { targetIssueId: string }).targetIssueId === issueId,
|
||||
);
|
||||
}
|
||||
|
||||
async issueIdsEverUnder(issueId: string) {
|
||||
return this.streams(
|
||||
'IssueParentChangedEvent',
|
||||
(d) => d['parentId'] === issueId,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Attachment storage double: files in a map, keyed like on disk. */
|
||||
|
||||
@@ -4,6 +4,8 @@ export * from './lib/issue/issue';
|
||||
export * from './lib/issue/issue.enums';
|
||||
export * from './lib/issue/issue-attachment';
|
||||
export * from './lib/issue/mentions';
|
||||
export * from './lib/issue/issue-hierarchy';
|
||||
export * from './lib/issue/issue-checklist';
|
||||
export * from './lib/project/project-settings';
|
||||
export * from './lib/comment/comment';
|
||||
export * from './lib/release/release';
|
||||
|
||||
@@ -26,3 +26,7 @@ export * from './issue-attachment-added.event';
|
||||
export * from './issue-attachment-removed.event';
|
||||
export * from './issue-watched.event';
|
||||
export * from './issue-unwatched.event';
|
||||
export * from './issue-parent-changed.event';
|
||||
export * from './issue-checklist-item-added.event';
|
||||
export * from './issue-checklist-item-updated.event';
|
||||
export * from './issue-checklist-item-removed.event';
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { AutowiredEvent } from '@task-stream/api/shared/domain';
|
||||
import { ChecklistItem } from '../issue/issue-checklist';
|
||||
import { TrackerEvent } from './tracker.event';
|
||||
|
||||
/** A step was added to a ticket's checklist. */
|
||||
@AutowiredEvent
|
||||
export class IssueChecklistItemAddedEvent extends TrackerEvent {
|
||||
constructor(
|
||||
actorId: string,
|
||||
issueId: string,
|
||||
projectId: string,
|
||||
readonly item: ChecklistItem,
|
||||
) {
|
||||
super(actorId, issueId, projectId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { AutowiredEvent } from '@task-stream/api/shared/domain';
|
||||
import { TrackerEvent } from './tracker.event';
|
||||
|
||||
/** A step was removed from a ticket's checklist. */
|
||||
@AutowiredEvent
|
||||
export class IssueChecklistItemRemovedEvent extends TrackerEvent {
|
||||
constructor(
|
||||
actorId: string,
|
||||
issueId: string,
|
||||
projectId: string,
|
||||
readonly itemId: string,
|
||||
readonly text: string,
|
||||
) {
|
||||
super(actorId, issueId, projectId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { AutowiredEvent } from '@task-stream/api/shared/domain';
|
||||
import { ChecklistItem } from '../issue/issue-checklist';
|
||||
import { TrackerEvent } from './tracker.event';
|
||||
|
||||
/** A checklist step was reworded, ticked or unticked (only what changed). */
|
||||
@AutowiredEvent
|
||||
export class IssueChecklistItemUpdatedEvent extends TrackerEvent {
|
||||
constructor(
|
||||
actorId: string,
|
||||
issueId: string,
|
||||
projectId: string,
|
||||
readonly itemId: string,
|
||||
readonly changes: Partial<Pick<ChecklistItem, 'text' | 'done'>>,
|
||||
) {
|
||||
super(actorId, issueId, projectId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { AutowiredEvent } from '@task-stream/api/shared/domain';
|
||||
import { TrackerEvent } from './tracker.event';
|
||||
|
||||
/** A ticket was placed under another one (or taken out: `parentId` null). */
|
||||
@AutowiredEvent
|
||||
export class IssueParentChangedEvent extends TrackerEvent {
|
||||
constructor(
|
||||
actorId: string,
|
||||
issueId: string,
|
||||
projectId: string,
|
||||
readonly parentId: string | null,
|
||||
readonly parentKey: string | null,
|
||||
readonly previousParentId: string | null,
|
||||
) {
|
||||
super(actorId, issueId, projectId);
|
||||
}
|
||||
}
|
||||
22
libs/api/tracker/domain/src/lib/issue/issue-checklist.ts
Normal file
22
libs/api/tracker/domain/src/lib/issue/issue-checklist.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { assertThat } from '@task-stream/api/shared/domain';
|
||||
|
||||
/** A step inside a ticket that does not deserve a ticket of its own. */
|
||||
export interface ChecklistItem {
|
||||
id: string;
|
||||
text: string;
|
||||
done: boolean;
|
||||
}
|
||||
|
||||
export const MAX_CHECKLIST_ITEMS = 100;
|
||||
export const MAX_CHECKLIST_TEXT = 300;
|
||||
|
||||
/** Trims and validates the wording of a checklist item. */
|
||||
export function normalizeChecklistText(text: string): string {
|
||||
const next = text.trim();
|
||||
assertThat(next.length > 0, 'A checklist item needs some text');
|
||||
assertThat(
|
||||
next.length <= MAX_CHECKLIST_TEXT,
|
||||
`A checklist item is at most ${MAX_CHECKLIST_TEXT} characters`,
|
||||
);
|
||||
return next;
|
||||
}
|
||||
42
libs/api/tracker/domain/src/lib/issue/issue-hierarchy.ts
Normal file
42
libs/api/tracker/domain/src/lib/issue/issue-hierarchy.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { BusinessRuleError } from '@task-stream/api/shared/domain';
|
||||
|
||||
/**
|
||||
* Levels below the top a ticket can sit: epic → ticket → subtask. Deeper
|
||||
* trees are hard to read on a board and nobody plans with them.
|
||||
*/
|
||||
export const MAX_HIERARCHY_DEPTH = 2;
|
||||
|
||||
/** The would-be parent of a ticket, as the command handler found it. */
|
||||
export interface ParentCandidate {
|
||||
id: string;
|
||||
key: string;
|
||||
projectId: string;
|
||||
/** Its own parent, grandparent… (closest first). */
|
||||
ancestorIds: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks that `childId` (of `childProjectId`, with descendants `childHeight`
|
||||
* levels deep) may hang from `parent`.
|
||||
*/
|
||||
export function assertCanNest(
|
||||
child: { id: string; key: string; projectId: string },
|
||||
parent: ParentCandidate,
|
||||
childHeight: number,
|
||||
): void {
|
||||
if (parent.id === child.id)
|
||||
throw new BusinessRuleError(`${child.key} cannot be its own parent`);
|
||||
if (parent.projectId !== child.projectId)
|
||||
throw new BusinessRuleError(
|
||||
`${parent.key} belongs to another project; a parent must be in the same project`,
|
||||
);
|
||||
if (parent.ancestorIds.includes(child.id))
|
||||
throw new BusinessRuleError(
|
||||
`${parent.key} already hangs from ${child.key}; that would make a loop`,
|
||||
);
|
||||
const depth = parent.ancestorIds.length + 1;
|
||||
if (depth + childHeight > MAX_HIERARCHY_DEPTH)
|
||||
throw new BusinessRuleError(
|
||||
`Tickets nest at most ${MAX_HIERARCHY_DEPTH} levels (epic → ticket → subtask)`,
|
||||
);
|
||||
}
|
||||
@@ -4,10 +4,12 @@ import {
|
||||
ValidationError,
|
||||
} from '@task-stream/api/shared/domain';
|
||||
import { IssueCreatedEvent } from '../events/issue-created.event';
|
||||
import { IssueParentChangedEvent } from '../events/issue-parent-changed.event';
|
||||
import { IssueStatusChangedEvent } from '../events/issue-status-changed.event';
|
||||
import { IssueFactory } from '../factories/issue.factory';
|
||||
import { replay } from '../testing';
|
||||
import { Issue } from './issue';
|
||||
import { ParentCandidate } from './issue-hierarchy';
|
||||
import { mentionedUserIds, withoutMentionMarkup } from './mentions';
|
||||
import { IssueLinkType, IssueStatus, IssueType } from './issue.enums';
|
||||
|
||||
@@ -223,6 +225,93 @@ describe('Issue', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('hierarchy', () => {
|
||||
const parent = (over: Partial<ParentCandidate> = {}): ParentCandidate => ({
|
||||
id: 'epic-1',
|
||||
key: 'TS-1',
|
||||
projectId: 'p-1',
|
||||
ancestorIds: [],
|
||||
...over,
|
||||
});
|
||||
|
||||
it('hangs from a parent, moves and leaves, and survives a replay', () => {
|
||||
const issue = newIssue();
|
||||
issue.setParent('u-pm', parent());
|
||||
issue.setParent('u-pm', parent());
|
||||
issue.setParent('u-pm', parent({ id: 'epic-2', key: 'TS-2' }));
|
||||
issue.setParent('u-pm', null);
|
||||
issue.setParent('u-pm', parent({ id: 'epic-3', key: 'TS-3' }));
|
||||
|
||||
const changes = issue
|
||||
.getUncommittedEvents()
|
||||
.filter((e) => e instanceof IssueParentChangedEvent);
|
||||
expect(changes.map((e) => [e.parentId, e.previousParentId])).toEqual([
|
||||
['epic-1', null],
|
||||
['epic-2', 'epic-1'],
|
||||
[null, 'epic-2'],
|
||||
['epic-3', null],
|
||||
]);
|
||||
expect(replay(issue, Issue).rebuilt.parentId).toBe('epic-3');
|
||||
});
|
||||
|
||||
it('refuses itself, another project, loops and a fourth level', () => {
|
||||
const issue = newIssue();
|
||||
expect(() => issue.setParent('u', parent({ id: issue.id }))).toThrow(
|
||||
BusinessRuleError,
|
||||
);
|
||||
expect(() => issue.setParent('u', parent({ projectId: 'p-2' }))).toThrow(
|
||||
/another project/,
|
||||
);
|
||||
expect(() =>
|
||||
issue.setParent('u', parent({ ancestorIds: [issue.id] })),
|
||||
).toThrow(/loop/);
|
||||
// Under a ticket that hangs from an epic: it becomes a subtask.
|
||||
expect(() =>
|
||||
issue.setParent('u', parent({ ancestorIds: ['t-1'] })),
|
||||
).not.toThrow();
|
||||
expect(() =>
|
||||
issue.setParent('u', parent({ id: 'x', ancestorIds: ['t-1', 'e-1'] })),
|
||||
).toThrow(/at most 2 levels/);
|
||||
// A ticket with subtasks cannot go below a ticket that has a parent.
|
||||
expect(() =>
|
||||
issue.setParent('u', parent({ id: 'y', ancestorIds: ['e-1'] }), 1),
|
||||
).toThrow(/at most 2 levels/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('checklist', () => {
|
||||
it('adds, ticks, rewords and removes steps, and survives a replay', () => {
|
||||
const issue = newIssue();
|
||||
issue.addChecklistItem('u-dev', 'c-1', ' Write the migration ');
|
||||
issue.addChecklistItem('u-dev', 'c-2', 'Update the docs');
|
||||
issue.addChecklistItem('u-dev', 'c-1', 'Duplicate id is ignored');
|
||||
issue.updateChecklistItem('u-dev', 'c-1', { done: true });
|
||||
issue.updateChecklistItem('u-dev', 'c-1', {
|
||||
done: true,
|
||||
text: 'Write the migration',
|
||||
});
|
||||
issue.updateChecklistItem('u-dev', 'c-2', { text: 'Update the README' });
|
||||
issue.removeChecklistItem('u-dev', 'c-2');
|
||||
|
||||
expect(issue.checklist).toEqual([
|
||||
{ id: 'c-1', text: 'Write the migration', done: true },
|
||||
]);
|
||||
expect(replay(issue, Issue).rebuilt.checklist).toEqual(issue.checklist);
|
||||
// created + 2 added + ticked + reworded + removed
|
||||
expect(issue.getUncommittedEvents()).toHaveLength(6);
|
||||
});
|
||||
|
||||
it('refuses empty steps and unknown items', () => {
|
||||
const issue = newIssue();
|
||||
expect(() => issue.addChecklistItem('u', 'c-1', ' ')).toThrow(
|
||||
ValidationError,
|
||||
);
|
||||
expect(() => issue.removeChecklistItem('u', 'nope')).toThrow(
|
||||
BusinessRuleError,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('mentions', () => {
|
||||
it('reads who a text mentions, once each', () => {
|
||||
const text =
|
||||
|
||||
@@ -7,9 +7,13 @@ import {
|
||||
import { IssueAssignedEvent } from '../events/issue-assigned.event';
|
||||
import { IssueAttachmentAddedEvent } from '../events/issue-attachment-added.event';
|
||||
import { IssueAttachmentRemovedEvent } from '../events/issue-attachment-removed.event';
|
||||
import { IssueChecklistItemAddedEvent } from '../events/issue-checklist-item-added.event';
|
||||
import { IssueChecklistItemRemovedEvent } from '../events/issue-checklist-item-removed.event';
|
||||
import { IssueChecklistItemUpdatedEvent } from '../events/issue-checklist-item-updated.event';
|
||||
import { IssueCreatedEvent } from '../events/issue-created.event';
|
||||
import { IssueDeletedEvent } from '../events/issue-deleted.event';
|
||||
import { IssueLinkedEvent } from '../events/issue-linked.event';
|
||||
import { IssueParentChangedEvent } from '../events/issue-parent-changed.event';
|
||||
import { IssueReleaseChangedEvent } from '../events/issue-release-changed.event';
|
||||
import { IssueStatusChangedEvent } from '../events/issue-status-changed.event';
|
||||
import { IssueUnlinkedEvent } from '../events/issue-unlinked.event';
|
||||
@@ -22,6 +26,12 @@ import {
|
||||
MAX_ATTACHMENTS_PER_ISSUE,
|
||||
normalizeAttachment,
|
||||
} from './issue-attachment';
|
||||
import {
|
||||
ChecklistItem,
|
||||
MAX_CHECKLIST_ITEMS,
|
||||
normalizeChecklistText,
|
||||
} from './issue-checklist';
|
||||
import { ParentCandidate, assertCanNest } from './issue-hierarchy';
|
||||
import { IssueLinkType, IssuePriority } from './issue.enums';
|
||||
|
||||
export interface IssueLink {
|
||||
@@ -95,6 +105,9 @@ export class Issue extends VersionedAggregateRoot implements IssueProps {
|
||||
public attachments: IssueAttachment[] = [];
|
||||
/** People who asked to be told about this ticket. */
|
||||
public watchers: string[] = [];
|
||||
/** The epic or ticket this one breaks down. */
|
||||
public parentId: string | null = null;
|
||||
public checklist: ChecklistItem[] = [];
|
||||
public createdAt!: string;
|
||||
public updatedAt!: string;
|
||||
public deleted = false;
|
||||
@@ -280,6 +293,91 @@ export class Issue extends VersionedAggregateRoot implements IssueProps {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Places the ticket under `parent` (null takes it out). `childHeight` is
|
||||
* how many levels of tickets already hang from this one.
|
||||
*/
|
||||
setParent(
|
||||
actorId: string,
|
||||
parent: ParentCandidate | null,
|
||||
childHeight = 0,
|
||||
): void {
|
||||
if ((parent?.id ?? null) === this.parentId) return;
|
||||
if (parent) assertCanNest(this, parent, childHeight);
|
||||
this.apply(
|
||||
new IssueParentChangedEvent(
|
||||
actorId,
|
||||
this.id,
|
||||
this.projectId,
|
||||
parent?.id ?? null,
|
||||
parent?.key ?? null,
|
||||
this.parentId,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/** Adds a step to the checklist; `id` is generated by the caller. */
|
||||
addChecklistItem(actorId: string, id: string, text: string): void {
|
||||
if (this.checklist.some((i) => i.id === id)) return;
|
||||
if (this.checklist.length >= MAX_CHECKLIST_ITEMS) {
|
||||
throw new BusinessRuleError(
|
||||
`${this.key} already has ${MAX_CHECKLIST_ITEMS} checklist items`,
|
||||
);
|
||||
}
|
||||
this.apply(
|
||||
new IssueChecklistItemAddedEvent(actorId, this.id, this.projectId, {
|
||||
id,
|
||||
text: normalizeChecklistText(text),
|
||||
done: false,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/** Rewords, ticks or unticks a step. Nothing changes, nothing happens. */
|
||||
updateChecklistItem(
|
||||
actorId: string,
|
||||
itemId: string,
|
||||
changes: { text?: string; done?: boolean },
|
||||
): void {
|
||||
const item = this.checklistItem(itemId);
|
||||
const patch: { text?: string; done?: boolean } = {};
|
||||
if (changes.text !== undefined) {
|
||||
const text = normalizeChecklistText(changes.text);
|
||||
if (text !== item.text) patch.text = text;
|
||||
}
|
||||
if (changes.done !== undefined && changes.done !== item.done)
|
||||
patch.done = changes.done;
|
||||
if (Object.keys(patch).length === 0) return;
|
||||
this.apply(
|
||||
new IssueChecklistItemUpdatedEvent(
|
||||
actorId,
|
||||
this.id,
|
||||
this.projectId,
|
||||
itemId,
|
||||
patch,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
removeChecklistItem(actorId: string, itemId: string): void {
|
||||
const item = this.checklistItem(itemId);
|
||||
this.apply(
|
||||
new IssueChecklistItemRemovedEvent(
|
||||
actorId,
|
||||
this.id,
|
||||
this.projectId,
|
||||
item.id,
|
||||
item.text,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
private checklistItem(itemId: string): ChecklistItem {
|
||||
const item = this.checklist.find((i) => i.id === itemId);
|
||||
if (!item) throw new BusinessRuleError('Checklist item not found');
|
||||
return item;
|
||||
}
|
||||
|
||||
/** Deletes the ticket (its project was deleted). Idempotent. */
|
||||
delete(actorId: string): void {
|
||||
if (this.deleted) return;
|
||||
@@ -360,6 +458,32 @@ export class Issue extends VersionedAggregateRoot implements IssueProps {
|
||||
this.watchers = this.watchers.filter((id) => id !== event.userId);
|
||||
}
|
||||
|
||||
onIssueParentChangedEvent(event: IssueParentChangedEvent): void {
|
||||
this.parentId = event.parentId;
|
||||
this.updatedAt = event.occurredAt;
|
||||
}
|
||||
|
||||
onIssueChecklistItemAddedEvent(event: IssueChecklistItemAddedEvent): void {
|
||||
this.checklist = [...this.checklist, { ...event.item }];
|
||||
this.updatedAt = event.occurredAt;
|
||||
}
|
||||
|
||||
onIssueChecklistItemUpdatedEvent(
|
||||
event: IssueChecklistItemUpdatedEvent,
|
||||
): void {
|
||||
this.checklist = this.checklist.map((i) =>
|
||||
i.id === event.itemId ? { ...i, ...event.changes } : i,
|
||||
);
|
||||
this.updatedAt = event.occurredAt;
|
||||
}
|
||||
|
||||
onIssueChecklistItemRemovedEvent(
|
||||
event: IssueChecklistItemRemovedEvent,
|
||||
): void {
|
||||
this.checklist = this.checklist.filter((i) => i.id !== event.itemId);
|
||||
this.updatedAt = event.occurredAt;
|
||||
}
|
||||
|
||||
onIssueDeletedEvent(): void {
|
||||
this.deleted = true;
|
||||
}
|
||||
|
||||
@@ -23,6 +23,35 @@ export class AttachmentReadModel {
|
||||
commentId!: string | null;
|
||||
}
|
||||
|
||||
export class ChecklistItemReadModel {
|
||||
id!: string;
|
||||
text!: string;
|
||||
done!: boolean;
|
||||
}
|
||||
|
||||
/** The ticket another one hangs from. */
|
||||
export class IssueParentReadModel {
|
||||
id!: string;
|
||||
key!: string;
|
||||
title!: string;
|
||||
}
|
||||
|
||||
/** How far the tickets hanging from one have got. */
|
||||
export class ChildProgressReadModel {
|
||||
total!: number;
|
||||
/** Done or cancelled. */
|
||||
finished!: number;
|
||||
percent!: number;
|
||||
storyPoints!: number;
|
||||
finishedStoryPoints!: number;
|
||||
}
|
||||
|
||||
/** A top-level ticket that groups others, with their progress. */
|
||||
export class EpicReadModel {
|
||||
issue!: IssueReadModel;
|
||||
progress!: ChildProgressReadModel;
|
||||
}
|
||||
|
||||
export class IssueReadModel {
|
||||
id!: string;
|
||||
projectId!: string;
|
||||
@@ -49,6 +78,8 @@ export class IssueReadModel {
|
||||
attachments!: AttachmentReadModel[];
|
||||
/** People following the ticket. */
|
||||
watchers!: UserRefReadModel[];
|
||||
parent!: IssueParentReadModel | null;
|
||||
checklist!: ChecklistItemReadModel[];
|
||||
commentCount!: number;
|
||||
createdAt!: string;
|
||||
updatedAt!: string;
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
import {
|
||||
ActivityReadModel,
|
||||
CommentReadModel,
|
||||
EpicReadModel,
|
||||
CountBy,
|
||||
defaultProjectSettings,
|
||||
IssueReadModel,
|
||||
@@ -228,6 +229,53 @@ export class MongoFindIssuesRepository extends FindIssuesRepository {
|
||||
return docs.map(issueFromDoc);
|
||||
}
|
||||
|
||||
async epics(projectId: string): Promise<EpicReadModel[]> {
|
||||
const finished = { $in: ['$statusCategory', FINISHED] };
|
||||
const points = { $ifNull: ['$storyPoints', 0] };
|
||||
const rows = await this.issues.aggregate<{
|
||||
_id: string;
|
||||
total: number;
|
||||
finished: number;
|
||||
storyPoints: number;
|
||||
finishedStoryPoints: number;
|
||||
}>([
|
||||
{ $match: { projectId, 'parent.id': { $type: 'string' } } },
|
||||
{
|
||||
$group: {
|
||||
_id: '$parent.id',
|
||||
total: { $sum: 1 },
|
||||
finished: { $sum: { $cond: [finished, 1, 0] } },
|
||||
storyPoints: { $sum: points },
|
||||
finishedStoryPoints: { $sum: { $cond: [finished, points, 0] } },
|
||||
},
|
||||
},
|
||||
]);
|
||||
if (rows.length === 0) return [];
|
||||
const progress = new Map(rows.map((row) => [row._id, row]));
|
||||
// Only the top of each tree: a ticket with subtasks under an epic is not one.
|
||||
const docs = await this.issues
|
||||
.find({ _id: { $in: [...progress.keys()] }, 'parent.id': null })
|
||||
.sort({ number: 1 })
|
||||
.lean<IssueDoc[]>();
|
||||
return docs.map((doc) => {
|
||||
const { _id: _ignored, ...row } = progress.get(doc._id) ?? {
|
||||
_id: doc._id,
|
||||
total: 0,
|
||||
finished: 0,
|
||||
storyPoints: 0,
|
||||
finishedStoryPoints: 0,
|
||||
};
|
||||
return {
|
||||
issue: issueFromDoc(doc),
|
||||
progress: {
|
||||
...row,
|
||||
percent:
|
||||
row.total === 0 ? 0 : Math.round((row.finished / row.total) * 100),
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async releaseProgress(releaseId: string): Promise<ReleaseProgressReadModel> {
|
||||
const rows = await this.issues.aggregate<{
|
||||
_id: { status: string; category: StatusCategory };
|
||||
@@ -268,6 +316,7 @@ function toMongoFilter(filter: IssueFilter): FilterQuery<IssueDoc> {
|
||||
if (filter.labels?.length)
|
||||
query.labels = { $all: filter.labels.map((l) => l.toLowerCase()) };
|
||||
if (filter.releaseId) query['release.id'] = filter.releaseId;
|
||||
if (filter.parentId) query['parent.id'] = filter.parentId;
|
||||
if (filter.unassigned) query['assignee'] = null;
|
||||
else if (filter.assigneeId) query['assignee.id'] = filter.assigneeId;
|
||||
if (filter.search?.trim()) {
|
||||
|
||||
@@ -78,6 +78,8 @@ export const IssueSchema = new Schema<IssueDoc>(
|
||||
outwardLinks: [link],
|
||||
inwardLinks: [link],
|
||||
watchers: [userRef],
|
||||
parent: { _id: false, id: String, key: String, title: String },
|
||||
checklist: [{ _id: false, id: String, text: String, done: Boolean }],
|
||||
attachments: [
|
||||
{
|
||||
_id: false,
|
||||
@@ -99,6 +101,7 @@ IssueSchema.index({ projectId: 1, status: 1, updatedAt: -1 });
|
||||
IssueSchema.index({ projectId: 1, statusCategory: 1 });
|
||||
IssueSchema.index({ 'assignee.id': 1 });
|
||||
IssueSchema.index({ 'watchers.id': 1 });
|
||||
IssueSchema.index({ 'parent.id': 1 });
|
||||
IssueSchema.index({ 'release.id': 1 });
|
||||
IssueSchema.index({ title: 'text', description: 'text', key: 'text' });
|
||||
|
||||
@@ -182,5 +185,7 @@ export function issueFromDoc(doc: IssueDoc): IssueReadModel {
|
||||
// Tickets projected before attachments existed have none.
|
||||
attachments: rest.attachments ?? [],
|
||||
watchers: rest.watchers ?? [],
|
||||
parent: rest.parent?.id ? rest.parent : null,
|
||||
checklist: rest.checklist ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
import {
|
||||
ActivityReadModel,
|
||||
AttachmentReadModel,
|
||||
ChecklistItemReadModel,
|
||||
CommentReadModel,
|
||||
IssueLinkReadModel,
|
||||
IssueReadModel,
|
||||
@@ -130,6 +131,45 @@ export class MongoUpsertMaterializedIssueRepository extends UpsertMaterializedIs
|
||||
);
|
||||
}
|
||||
|
||||
async addChecklistItem(issueId: string, item: ChecklistItemReadModel) {
|
||||
await this.issues.updateOne(
|
||||
{ _id: issueId, 'checklist.id': { $ne: item.id } },
|
||||
{ $push: { checklist: item } },
|
||||
);
|
||||
}
|
||||
|
||||
async updateChecklistItem(
|
||||
issueId: string,
|
||||
itemId: string,
|
||||
changes: Partial<Pick<ChecklistItemReadModel, 'text' | 'done'>>,
|
||||
) {
|
||||
const set = Object.fromEntries(
|
||||
Object.entries(changes).map(([field, value]) => [
|
||||
`checklist.$.${field}`,
|
||||
value,
|
||||
]),
|
||||
);
|
||||
if (Object.keys(set).length === 0) return;
|
||||
await this.issues.updateOne(
|
||||
{ _id: issueId, 'checklist.id': itemId },
|
||||
{ $set: set },
|
||||
);
|
||||
}
|
||||
|
||||
async removeChecklistItem(issueId: string, itemId: string) {
|
||||
await this.issues.updateOne(
|
||||
{ _id: issueId },
|
||||
{ $pull: { checklist: { id: itemId } } },
|
||||
);
|
||||
}
|
||||
|
||||
async renameParent(parentId: string, title: string) {
|
||||
await this.issues.updateMany(
|
||||
{ 'parent.id': parentId },
|
||||
{ $set: { 'parent.title': title } },
|
||||
);
|
||||
}
|
||||
|
||||
async addAttachment(issueId: string, attachment: AttachmentReadModel) {
|
||||
await this.issues.updateOne(
|
||||
{ _id: issueId, 'attachments.id': { $ne: attachment.id } },
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
CommentAddedEvent,
|
||||
IssueCreatedEvent,
|
||||
IssueLinkedEvent,
|
||||
IssueParentChangedEvent,
|
||||
ReleaseCreatedEvent,
|
||||
} from '@task-stream/api/tracker/domain';
|
||||
import { DataSource } from 'typeorm';
|
||||
@@ -47,6 +48,14 @@ export class PostgresTrackerStreamsRepository extends TrackerStreamsRepository {
|
||||
);
|
||||
}
|
||||
|
||||
issueIdsEverUnder(issueId: string): Promise<string[]> {
|
||||
return this.streams(
|
||||
IssueParentChangedEvent.name,
|
||||
`data->>'parentId'`,
|
||||
issueId,
|
||||
);
|
||||
}
|
||||
|
||||
/** `field` is one of the fixed JSON paths above, never user input. */
|
||||
private async streams(
|
||||
type: string,
|
||||
|
||||
@@ -140,6 +140,13 @@ export class CreateIssueInput extends IssueFieldsInput {
|
||||
@Field(() => ID, { nullable: true }) @IsOptional() @IsUUID() releaseId?:
|
||||
| string
|
||||
| null;
|
||||
@Field(() => ID, {
|
||||
nullable: true,
|
||||
description: 'Epic or ticket of the same project the new one breaks down',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
parentId?: string | null;
|
||||
}
|
||||
|
||||
@InputType()
|
||||
@@ -173,6 +180,25 @@ export class ChangeIssueStatusInput {
|
||||
reason?: string | null;
|
||||
}
|
||||
|
||||
@InputType()
|
||||
export class AddChecklistItemInput {
|
||||
@Field(() => ID) @IsUUID() issueId!: string;
|
||||
@Field() @IsString() @MinLength(1) @MaxLength(300) text!: string;
|
||||
}
|
||||
|
||||
@InputType()
|
||||
export class UpdateChecklistItemInput {
|
||||
@Field(() => ID) @IsUUID() issueId!: string;
|
||||
@Field(() => ID) @IsUUID() itemId!: string;
|
||||
@Field({ nullable: true })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(300)
|
||||
text?: string;
|
||||
@Field({ nullable: true }) @IsOptional() @IsBoolean() done?: boolean;
|
||||
}
|
||||
|
||||
@InputType()
|
||||
export class LinkIssuesInput {
|
||||
@Field(() => ID) @IsUUID() issueId!: string;
|
||||
@@ -216,6 +242,13 @@ export class IssueFilterInput {
|
||||
@IsOptional()
|
||||
@IsString({ each: true })
|
||||
labels?: string[];
|
||||
@Field(() => ID, {
|
||||
nullable: true,
|
||||
description: 'Only the tickets hanging from this one',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
parentId?: string;
|
||||
@Field({ nullable: true, description: 'Matches key, title or description' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
|
||||
@@ -23,10 +23,12 @@ import {
|
||||
StatusCategory,
|
||||
} from '@task-stream/api/tracker/domain';
|
||||
import {
|
||||
AddChecklistItemInput,
|
||||
ChangeIssueStatusInput,
|
||||
CreateIssueInput,
|
||||
IssueFilterInput,
|
||||
LinkIssuesInput,
|
||||
UpdateChecklistItemInput,
|
||||
UpdateIssueInput,
|
||||
} from './inputs';
|
||||
import { withoutNulls } from '../common/issue-input';
|
||||
@@ -35,6 +37,7 @@ import {
|
||||
ActivityGql,
|
||||
CommandResultGql,
|
||||
CommentPageGql,
|
||||
EpicGql,
|
||||
IssueGql,
|
||||
IssuePageGql,
|
||||
} from './types';
|
||||
@@ -72,6 +75,18 @@ export class IssueResolver {
|
||||
});
|
||||
}
|
||||
|
||||
@OptionalAuth()
|
||||
@Query(() => [EpicGql], {
|
||||
description:
|
||||
"A project's epics: top-level tickets that group others, with the progress of those",
|
||||
})
|
||||
epics(
|
||||
@CurrentUser() me: User | undefined,
|
||||
@Args('projectId', { type: () => ID }) projectId: string,
|
||||
) {
|
||||
return this.issuesService.epics(viewerOf(me), projectId);
|
||||
}
|
||||
|
||||
@Query(() => IssuePageGql, {
|
||||
description:
|
||||
'Tickets across all my projects: assigned to me (default) or any (e.g. categories [QA])',
|
||||
@@ -238,6 +253,56 @@ export class IssueResolver {
|
||||
return this.issuesService.unlink(me.id, input);
|
||||
}
|
||||
|
||||
@Mutation(() => CommandResultGql, {
|
||||
description:
|
||||
'Place a ticket under an epic or another ticket of the same project (null: back to the top). Three levels at most: epic → ticket → subtask.',
|
||||
})
|
||||
setIssueParent(
|
||||
@CurrentUser() me: User,
|
||||
@Args('issueId', { type: () => ID }) issueId: string,
|
||||
@Args('parentId', { type: () => ID, nullable: true })
|
||||
parentId: string | null,
|
||||
) {
|
||||
return this.issuesService.setParent(me.id, issueId, parentId ?? null);
|
||||
}
|
||||
|
||||
@Mutation(() => CommandResultGql, {
|
||||
description: "Add a step to a ticket's checklist",
|
||||
})
|
||||
addChecklistItem(
|
||||
@CurrentUser() me: User,
|
||||
@Args('input') input: AddChecklistItemInput,
|
||||
) {
|
||||
return this.issuesService.addChecklistItem(
|
||||
me.id,
|
||||
input.issueId,
|
||||
input.text,
|
||||
);
|
||||
}
|
||||
|
||||
@Mutation(() => CommandResultGql, {
|
||||
description: 'Reword, tick or untick a checklist step',
|
||||
})
|
||||
updateChecklistItem(
|
||||
@CurrentUser() me: User,
|
||||
@Args('input') input: UpdateChecklistItemInput,
|
||||
) {
|
||||
const { issueId, itemId, text, done } = input;
|
||||
return this.issuesService.updateChecklistItem(me.id, issueId, itemId, {
|
||||
...(text != null && { text }),
|
||||
...(done != null && { done }),
|
||||
});
|
||||
}
|
||||
|
||||
@Mutation(() => CommandResultGql)
|
||||
removeChecklistItem(
|
||||
@CurrentUser() me: User,
|
||||
@Args('issueId', { type: () => ID }) issueId: string,
|
||||
@Args('itemId', { type: () => ID }) itemId: string,
|
||||
) {
|
||||
return this.issuesService.removeChecklistItem(me.id, issueId, itemId);
|
||||
}
|
||||
|
||||
@Mutation(() => CommandResultGql, {
|
||||
description: 'Put a ticket in a release (null removes it)',
|
||||
})
|
||||
|
||||
@@ -116,6 +116,23 @@ export class ReleaseRefGql {
|
||||
@Field() name!: string;
|
||||
}
|
||||
|
||||
@ObjectType('ChecklistItem', {
|
||||
description:
|
||||
'A step inside a ticket that does not deserve a ticket of its own',
|
||||
})
|
||||
export class ChecklistItemGql {
|
||||
@Field(() => ID) id!: string;
|
||||
@Field() text!: string;
|
||||
@Field() done!: boolean;
|
||||
}
|
||||
|
||||
@ObjectType('IssueParent', { description: 'The ticket another one hangs from' })
|
||||
export class IssueParentGql {
|
||||
@Field(() => ID) id!: string;
|
||||
@Field() key!: string;
|
||||
@Field() title!: string;
|
||||
}
|
||||
|
||||
@ObjectType('Issue')
|
||||
export class IssueGql {
|
||||
@Field(() => ID) id!: string;
|
||||
@@ -145,12 +162,37 @@ export class IssueGql {
|
||||
@Field(() => [AttachmentGql]) attachments!: AttachmentGql[];
|
||||
@Field(() => [UserRefGql], { description: 'People following the ticket' })
|
||||
watchers!: UserRefGql[];
|
||||
@Field(() => IssueParentGql, {
|
||||
nullable: true,
|
||||
description: 'The epic or ticket this one breaks down',
|
||||
})
|
||||
parent!: IssueParentGql | null;
|
||||
@Field(() => [ChecklistItemGql]) checklist!: ChecklistItemGql[];
|
||||
@Field(() => Int) commentCount!: number;
|
||||
@Field() createdAt!: string;
|
||||
@Field() updatedAt!: string;
|
||||
@Field(() => Int) version!: number;
|
||||
}
|
||||
|
||||
@ObjectType('ChildProgress', {
|
||||
description: 'How far the tickets hanging from one have got',
|
||||
})
|
||||
export class ChildProgressGql {
|
||||
@Field(() => Int) total!: number;
|
||||
@Field(() => Int, { description: 'Done or cancelled' }) finished!: number;
|
||||
@Field(() => Int) percent!: number;
|
||||
@Field(() => Int) storyPoints!: number;
|
||||
@Field(() => Int) finishedStoryPoints!: number;
|
||||
}
|
||||
|
||||
@ObjectType('Epic', {
|
||||
description: 'A top-level ticket that groups others, with their progress',
|
||||
})
|
||||
export class EpicGql {
|
||||
@Field(() => IssueGql) issue!: IssueGql;
|
||||
@Field(() => ChildProgressGql) progress!: ChildProgressGql;
|
||||
}
|
||||
|
||||
@ObjectType('IssuePage')
|
||||
export class IssuePageGql {
|
||||
@Field(() => [IssueGql]) items!: IssueGql[];
|
||||
|
||||
@@ -408,6 +408,14 @@ export class IssueSearchQueryDto extends PageQueryDto {
|
||||
@IsString({ each: true })
|
||||
label?: string[];
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Only the tickets hanging from this one (id or key)',
|
||||
example: 'WEB-3',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
parent?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Matches key, title or description' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@@ -501,6 +509,17 @@ export class CreateIssueDto extends IssueFieldsDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
releaseId?: string | null;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
type: String,
|
||||
nullable: true,
|
||||
example: 'WEB-3',
|
||||
description:
|
||||
'Epic or ticket of the same project the new one breaks down (id or key)',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
parent?: string | null;
|
||||
}
|
||||
|
||||
export class UpdateIssueDto extends IssueFieldsDto {
|
||||
@@ -560,6 +579,41 @@ export class SetIssueReleaseDto {
|
||||
releaseId!: string | null;
|
||||
}
|
||||
|
||||
export class SetIssueParentDto {
|
||||
@ApiProperty({
|
||||
type: String,
|
||||
nullable: true,
|
||||
example: 'WEB-3',
|
||||
description:
|
||||
'Epic or ticket of the same project (id or key), or null to take it back to the top',
|
||||
})
|
||||
@ValidateIf((dto: SetIssueParentDto) => dto.parent !== null)
|
||||
@IsString()
|
||||
parent!: string | null;
|
||||
}
|
||||
|
||||
export class ChecklistItemBodyDto {
|
||||
@ApiProperty({ example: 'Actualizar el changelog', maxLength: 300 })
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(300)
|
||||
text!: string;
|
||||
}
|
||||
|
||||
export class UpdateChecklistItemDto {
|
||||
@ApiPropertyOptional({ maxLength: 300 })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(300)
|
||||
text?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
done?: boolean;
|
||||
}
|
||||
|
||||
export class LinkIssueDto {
|
||||
@ApiProperty({ enum: IssueLinkType, enumName: 'IssueLinkType' })
|
||||
@IsEnum(IssueLinkType)
|
||||
|
||||
@@ -123,6 +123,18 @@ export class AttachmentDto {
|
||||
commentId!: string | null;
|
||||
}
|
||||
|
||||
export class ChecklistItemDto {
|
||||
@ApiProperty({ format: 'uuid' }) id!: string;
|
||||
@ApiProperty({ example: 'Actualizar el changelog' }) text!: string;
|
||||
@ApiProperty() done!: boolean;
|
||||
}
|
||||
|
||||
export class IssueParentDto {
|
||||
@ApiProperty({ format: 'uuid' }) id!: string;
|
||||
@ApiProperty({ example: 'WEB-3' }) key!: string;
|
||||
@ApiProperty({ example: 'Pago con tarjeta' }) title!: string;
|
||||
}
|
||||
|
||||
export class IssueDto {
|
||||
@ApiProperty({ format: 'uuid' }) id!: string;
|
||||
@ApiProperty({ format: 'uuid' }) projectId!: string;
|
||||
@@ -161,6 +173,13 @@ export class IssueDto {
|
||||
@ApiProperty({ type: [AttachmentDto] }) attachments!: AttachmentDto[];
|
||||
@ApiProperty({ type: [UserRefDto], description: 'People following it' })
|
||||
watchers!: UserRefDto[];
|
||||
@ApiProperty({
|
||||
type: IssueParentDto,
|
||||
nullable: true,
|
||||
description: 'The epic or ticket this one breaks down',
|
||||
})
|
||||
parent!: IssueParentDto | null;
|
||||
@ApiProperty({ type: [ChecklistItemDto] }) checklist!: ChecklistItemDto[];
|
||||
@ApiProperty() commentCount!: number;
|
||||
@ApiProperty({ format: 'date-time' }) createdAt!: string;
|
||||
@ApiProperty({ format: 'date-time' }) updatedAt!: string;
|
||||
@@ -233,6 +252,19 @@ export class CountByAssigneeDto {
|
||||
@ApiProperty() count!: number;
|
||||
}
|
||||
|
||||
export class ChildProgressDto {
|
||||
@ApiProperty() total!: number;
|
||||
@ApiProperty({ description: 'Done or cancelled' }) finished!: number;
|
||||
@ApiProperty({ minimum: 0, maximum: 100 }) percent!: number;
|
||||
@ApiProperty() storyPoints!: number;
|
||||
@ApiProperty() finishedStoryPoints!: number;
|
||||
}
|
||||
|
||||
export class EpicDto {
|
||||
@ApiProperty({ type: IssueDto }) issue!: IssueDto;
|
||||
@ApiProperty({ type: ChildProgressDto }) progress!: ChildProgressDto;
|
||||
}
|
||||
|
||||
export class ReleaseProgressDto {
|
||||
@ApiProperty() total!: number;
|
||||
@ApiProperty() finished!: number;
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Patch,
|
||||
Post,
|
||||
Put,
|
||||
@@ -35,6 +36,7 @@ import { withoutNulls } from '../common/issue-input';
|
||||
import {
|
||||
AssignIssueDto,
|
||||
ChangeStatusDto,
|
||||
ChecklistItemBodyDto,
|
||||
CommentBodyDto,
|
||||
CreateIssueDto,
|
||||
IssueSearchQueryDto,
|
||||
@@ -42,13 +44,16 @@ import {
|
||||
LinkTypeParamDto,
|
||||
MyIssuesQueryDto,
|
||||
PageQueryDto,
|
||||
SetIssueParentDto,
|
||||
SetIssueReleaseDto,
|
||||
UpdateChecklistItemDto,
|
||||
UpdateIssueDto,
|
||||
} from './dto/requests.dto';
|
||||
import {
|
||||
ActivityDto,
|
||||
CommandResultDto,
|
||||
CommentPageDto,
|
||||
EpicDto,
|
||||
IssueDto,
|
||||
IssuePageDto,
|
||||
} from './dto/responses.dto';
|
||||
@@ -139,6 +144,9 @@ export class IssuesController {
|
||||
) {
|
||||
const page = await this.issues.search(viewerOf(me), {
|
||||
projectId: await this.refs.projectId(viewerOf(me), project),
|
||||
parentId: query.parent
|
||||
? await this.refs.issueId(viewerOf(me), query.parent)
|
||||
: undefined,
|
||||
statuses: query.status,
|
||||
categories: query.category,
|
||||
types: query.type,
|
||||
@@ -169,12 +177,40 @@ export class IssuesController {
|
||||
@Param('project') project: string,
|
||||
@Body() body: CreateIssueDto,
|
||||
) {
|
||||
const { parent, ...fields } = body;
|
||||
return this.issues.create(me.id, {
|
||||
...withoutNulls(body),
|
||||
...withoutNulls(fields),
|
||||
projectId: await this.refs.projectId(me.id, project),
|
||||
parentId: parent ? await this.refs.issueId(me.id, parent) : null,
|
||||
});
|
||||
}
|
||||
|
||||
@Get('projects/:project/epics')
|
||||
@ApiOptionalPireonAuth()
|
||||
@PROJECT
|
||||
@ApiOperation({
|
||||
summary: 'Epics: top-level tickets that group others',
|
||||
description:
|
||||
'With the progress of the tickets hanging from each one (done or cancelled, and story points).',
|
||||
})
|
||||
@ApiOkResponse({ type: [EpicDto] })
|
||||
@ApiReadErrors()
|
||||
async epics(
|
||||
@CurrentUser() me: User | undefined,
|
||||
@Param('project') project: string,
|
||||
) {
|
||||
const epics = await this.issues.epics(
|
||||
viewerOf(me),
|
||||
await this.refs.projectId(viewerOf(me), project),
|
||||
);
|
||||
return Promise.all(
|
||||
epics.map(async (epic) => ({
|
||||
...epic,
|
||||
issue: await this.refs.issueDto(epic.issue),
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
@Get('issues/:issue')
|
||||
@ApiOptionalPireonAuth()
|
||||
@ISSUE
|
||||
@@ -266,6 +302,82 @@ export class IssuesController {
|
||||
);
|
||||
}
|
||||
|
||||
@Put('issues/:issue/parent')
|
||||
@ISSUE
|
||||
@ApiOperation({
|
||||
summary: 'Place the ticket under an epic or another ticket',
|
||||
description:
|
||||
'Same project, three levels at most (epic → ticket → subtask), no loops. null takes it back to the top.',
|
||||
})
|
||||
@ApiOkResponse({ type: CommandResultDto })
|
||||
@ApiCommandErrors()
|
||||
async setParent(
|
||||
@CurrentUser() me: User,
|
||||
@Param('issue') issue: string,
|
||||
@Body() body: SetIssueParentDto,
|
||||
) {
|
||||
return this.issues.setParent(
|
||||
me.id,
|
||||
await this.refs.issueId(me.id, issue),
|
||||
body.parent ? await this.refs.issueId(me.id, body.parent) : null,
|
||||
);
|
||||
}
|
||||
|
||||
@Post('issues/:issue/checklist')
|
||||
@ISSUE
|
||||
@ApiOperation({ summary: "Add a step to the ticket's checklist" })
|
||||
@ApiCreatedResponse({ type: CommandResultDto })
|
||||
@ApiCommandErrors()
|
||||
async addChecklistItem(
|
||||
@CurrentUser() me: User,
|
||||
@Param('issue') issue: string,
|
||||
@Body() body: ChecklistItemBodyDto,
|
||||
) {
|
||||
return this.issues.addChecklistItem(
|
||||
me.id,
|
||||
await this.refs.issueId(me.id, issue),
|
||||
body.text,
|
||||
);
|
||||
}
|
||||
|
||||
@Patch('issues/:issue/checklist/:item')
|
||||
@ISSUE
|
||||
@ApiParam({ name: 'item', format: 'uuid' })
|
||||
@ApiOperation({ summary: 'Reword, tick or untick a checklist step' })
|
||||
@ApiOkResponse({ type: CommandResultDto })
|
||||
@ApiCommandErrors()
|
||||
async updateChecklistItem(
|
||||
@CurrentUser() me: User,
|
||||
@Param('issue') issue: string,
|
||||
@Param('item', ParseUUIDPipe) item: string,
|
||||
@Body() body: UpdateChecklistItemDto,
|
||||
) {
|
||||
return this.issues.updateChecklistItem(
|
||||
me.id,
|
||||
await this.refs.issueId(me.id, issue),
|
||||
item,
|
||||
body,
|
||||
);
|
||||
}
|
||||
|
||||
@Delete('issues/:issue/checklist/:item')
|
||||
@ISSUE
|
||||
@ApiParam({ name: 'item', format: 'uuid' })
|
||||
@ApiOperation({ summary: 'Remove a checklist step' })
|
||||
@ApiOkResponse({ type: CommandResultDto })
|
||||
@ApiCommandErrors()
|
||||
async removeChecklistItem(
|
||||
@CurrentUser() me: User,
|
||||
@Param('issue') issue: string,
|
||||
@Param('item', ParseUUIDPipe) item: string,
|
||||
) {
|
||||
return this.issues.removeChecklistItem(
|
||||
me.id,
|
||||
await this.refs.issueId(me.id, issue),
|
||||
item,
|
||||
);
|
||||
}
|
||||
|
||||
@Post('issues/:issue/links')
|
||||
@ISSUE
|
||||
@ApiOperation({
|
||||
|
||||
Reference in New Issue
Block a user