feat: mentions and following tickets
Mention project members in comments with @, follow and unfollow tickets, and get told when something changes on the tickets you follow. Commenting on a ticket starts following it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -3,6 +3,7 @@ export * from './lib/project/project-role';
|
||||
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/project/project-settings';
|
||||
export * from './lib/comment/comment';
|
||||
export * from './lib/release/release';
|
||||
|
||||
@@ -24,3 +24,5 @@ export * from './issue-deleted.event';
|
||||
export * from './release-deleted.event';
|
||||
export * from './issue-attachment-added.event';
|
||||
export * from './issue-attachment-removed.event';
|
||||
export * from './issue-watched.event';
|
||||
export * from './issue-unwatched.event';
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { AutowiredEvent } from '@task-stream/api/shared/domain';
|
||||
import { TrackerEvent } from './tracker.event';
|
||||
|
||||
/** Someone stopped following a ticket. */
|
||||
@AutowiredEvent
|
||||
export class IssueUnwatchedEvent extends TrackerEvent {
|
||||
constructor(
|
||||
actorId: string,
|
||||
issueId: string,
|
||||
projectId: string,
|
||||
readonly userId: string,
|
||||
) {
|
||||
super(actorId, issueId, projectId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { AutowiredEvent } from '@task-stream/api/shared/domain';
|
||||
import { TrackerEvent } from './tracker.event';
|
||||
|
||||
/** Someone started following a ticket. */
|
||||
@AutowiredEvent
|
||||
export class IssueWatchedEvent extends TrackerEvent {
|
||||
constructor(
|
||||
actorId: string,
|
||||
issueId: string,
|
||||
projectId: string,
|
||||
readonly userId: string,
|
||||
) {
|
||||
super(actorId, issueId, projectId);
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { IssueStatusChangedEvent } from '../events/issue-status-changed.event';
|
||||
import { IssueFactory } from '../factories/issue.factory';
|
||||
import { replay } from '../testing';
|
||||
import { Issue } from './issue';
|
||||
import { mentionedUserIds, withoutMentionMarkup } from './mentions';
|
||||
import { IssueLinkType, IssueStatus, IssueType } from './issue.enums';
|
||||
|
||||
const factory = new IssueFactory();
|
||||
@@ -206,4 +207,31 @@ describe('Issue', () => {
|
||||
expect(rebuilt.attachments.map((a) => a.id)).toEqual(['a-2']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('followers', () => {
|
||||
it('follows and unfollows once, and survives a replay', () => {
|
||||
const issue = newIssue();
|
||||
issue.watch('u-dev', 'u-dev');
|
||||
issue.watch('u-dev', 'u-dev');
|
||||
issue.watch('u-pm', 'u-pm');
|
||||
issue.unwatch('u-dev', 'u-dev');
|
||||
issue.unwatch('u-dev', 'u-dev');
|
||||
|
||||
expect(issue.watchers).toEqual(['u-pm']);
|
||||
expect(replay(issue, Issue).rebuilt.watchers).toEqual(['u-pm']);
|
||||
expect(issue.getUncommittedEvents()).toHaveLength(4);
|
||||
});
|
||||
});
|
||||
|
||||
describe('mentions', () => {
|
||||
it('reads who a text mentions, once each', () => {
|
||||
const text =
|
||||
'Hola @[Ana García](u-ana) y @[Bob](u-bob), @[Ana García](u-ana) otra vez. @sinmarca';
|
||||
|
||||
expect(mentionedUserIds(text)).toEqual(['u-ana', 'u-bob']);
|
||||
expect(withoutMentionMarkup(text)).toBe(
|
||||
'Hola @Ana García y @Bob, @Ana García otra vez. @sinmarca',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,6 +14,8 @@ import { IssueReleaseChangedEvent } from '../events/issue-release-changed.event'
|
||||
import { IssueStatusChangedEvent } from '../events/issue-status-changed.event';
|
||||
import { IssueUnlinkedEvent } from '../events/issue-unlinked.event';
|
||||
import { IssueUpdatedEvent } from '../events/issue-updated.event';
|
||||
import { IssueUnwatchedEvent } from '../events/issue-unwatched.event';
|
||||
import { IssueWatchedEvent } from '../events/issue-watched.event';
|
||||
import { ProjectWorkflow } from '../project/project-settings';
|
||||
import {
|
||||
IssueAttachment,
|
||||
@@ -91,6 +93,8 @@ export class Issue extends VersionedAggregateRoot implements IssueProps {
|
||||
public dueDate: string | null = null;
|
||||
public links: IssueLink[] = [];
|
||||
public attachments: IssueAttachment[] = [];
|
||||
/** People who asked to be told about this ticket. */
|
||||
public watchers: string[] = [];
|
||||
public createdAt!: string;
|
||||
public updatedAt!: string;
|
||||
public deleted = false;
|
||||
@@ -262,6 +266,20 @@ export class Issue extends VersionedAggregateRoot implements IssueProps {
|
||||
);
|
||||
}
|
||||
|
||||
/** `userId` starts following the ticket. Idempotent. */
|
||||
watch(actorId: string, userId: string): void {
|
||||
if (this.watchers.includes(userId)) return;
|
||||
this.apply(new IssueWatchedEvent(actorId, this.id, this.projectId, userId));
|
||||
}
|
||||
|
||||
/** `userId` stops following the ticket. Idempotent. */
|
||||
unwatch(actorId: string, userId: string): void {
|
||||
if (!this.watchers.includes(userId)) return;
|
||||
this.apply(
|
||||
new IssueUnwatchedEvent(actorId, this.id, this.projectId, userId),
|
||||
);
|
||||
}
|
||||
|
||||
/** Deletes the ticket (its project was deleted). Idempotent. */
|
||||
delete(actorId: string): void {
|
||||
if (this.deleted) return;
|
||||
@@ -334,6 +352,14 @@ export class Issue extends VersionedAggregateRoot implements IssueProps {
|
||||
this.updatedAt = event.occurredAt;
|
||||
}
|
||||
|
||||
onIssueWatchedEvent(event: IssueWatchedEvent): void {
|
||||
this.watchers = [...this.watchers, event.userId];
|
||||
}
|
||||
|
||||
onIssueUnwatchedEvent(event: IssueUnwatchedEvent): void {
|
||||
this.watchers = this.watchers.filter((id) => id !== event.userId);
|
||||
}
|
||||
|
||||
onIssueDeletedEvent(): void {
|
||||
this.deleted = true;
|
||||
}
|
||||
|
||||
17
libs/api/tracker/domain/src/lib/issue/mentions.ts
Normal file
17
libs/api/tracker/domain/src/lib/issue/mentions.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* Mentions are written `@[Ana García](<user id>)`: the name to show and who
|
||||
* it is, so renaming someone never breaks an old comment. The web app inserts
|
||||
* them from a list of the project's members.
|
||||
*/
|
||||
const MENTION = /@\[([^\]\n]{1,80})\]\(([\w-]{1,64})\)/g;
|
||||
|
||||
/** Who a text mentions, once each, in order. */
|
||||
export function mentionedUserIds(text: string | null | undefined): string[] {
|
||||
if (!text) return [];
|
||||
return [...new Set([...text.matchAll(MENTION)].map((m) => m[2]))];
|
||||
}
|
||||
|
||||
/** The text as a person reads it: `@Ana García` instead of the markup. */
|
||||
export function withoutMentionMarkup(text: string): string {
|
||||
return text.replace(MENTION, (_, name: string) => `@${name}`);
|
||||
}
|
||||
@@ -47,6 +47,8 @@ export class IssueReadModel {
|
||||
dueDate!: string | null;
|
||||
links!: IssueLinkReadModel[];
|
||||
attachments!: AttachmentReadModel[];
|
||||
/** People following the ticket. */
|
||||
watchers!: UserRefReadModel[];
|
||||
commentCount!: number;
|
||||
createdAt!: string;
|
||||
updatedAt!: string;
|
||||
|
||||
Reference in New Issue
Block a user