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:
@@ -4,10 +4,12 @@ import {
|
||||
IssueAssignedEvent,
|
||||
IssueCreatedEvent,
|
||||
IssueStatusChangedEvent,
|
||||
mentionedUserIds,
|
||||
NotificationKind,
|
||||
ProjectMemberAddedEvent,
|
||||
StatusCategory,
|
||||
TrackerEvent,
|
||||
withoutMentionMarkup,
|
||||
} from '@task-stream/api/tracker/domain';
|
||||
import { FindIssuesRepository } from '../ports/find-issues.repository';
|
||||
import { FindProjectsRepository } from '../ports/find-projects.repository';
|
||||
@@ -17,14 +19,16 @@ import { NotificationDraft, Notifier } from './notifier';
|
||||
/* Read by people, like the activity feed: Spanish. */
|
||||
|
||||
const excerpt = (text: string, max = 120) => {
|
||||
const flat = text.replace(/\s+/g, ' ').trim();
|
||||
const flat = withoutMentionMarkup(text).replace(/\s+/g, ' ').trim();
|
||||
return flat.length > max ? `${flat.slice(0, max - 1)}…` : flat;
|
||||
};
|
||||
|
||||
/**
|
||||
* Who is told what, for the events that concern someone in particular:
|
||||
* whoever a ticket is assigned to, whoever reported or works on it when it
|
||||
* moves or gets a comment, whoever joins a project.
|
||||
* whoever is mentioned, whoever a ticket is assigned to, whoever reported or
|
||||
* works on it when it moves or gets a comment, whoever follows it, whoever
|
||||
* joins a project. The order is the order of importance: each person gets
|
||||
* one notification per event, for the first reason that applies.
|
||||
*/
|
||||
@EventsHandler(
|
||||
IssueCreatedEvent,
|
||||
@@ -81,6 +85,9 @@ export class NotificationsEventHandler implements IEventHandler<TrackerEvent> {
|
||||
const assigneeId =
|
||||
'assigneeId' in issue ? issue.assigneeId : (issue.assignee?.id ?? null);
|
||||
const ticket = `${issue.key}: ${issue.title}`;
|
||||
const watcherIds =
|
||||
'watchers' in issue ? issue.watchers.map((w) => w.id) : [];
|
||||
const memberIds = new Set(project.members.map((m) => m.user.id));
|
||||
|
||||
const drafts: NotificationDraft[] = [];
|
||||
if (event instanceof IssueCreatedEvent && assigneeId) {
|
||||
@@ -90,12 +97,26 @@ export class NotificationsEventHandler implements IEventHandler<TrackerEvent> {
|
||||
summary: `${actor} te asignó ${ticket}`,
|
||||
});
|
||||
}
|
||||
if (event instanceof IssueAssignedEvent && event.assigneeId) {
|
||||
drafts.push({
|
||||
userId: event.assigneeId,
|
||||
kind: NotificationKind.ASSIGNED,
|
||||
summary: `${actor} te asignó ${ticket}`,
|
||||
});
|
||||
if (event instanceof IssueAssignedEvent) {
|
||||
const assignee = event.assigneeId
|
||||
? (await this.users.refs([event.assigneeId])).of(event.assigneeId).name
|
||||
: null;
|
||||
if (event.assigneeId) {
|
||||
drafts.push({
|
||||
userId: event.assigneeId,
|
||||
kind: NotificationKind.ASSIGNED,
|
||||
summary: `${actor} te asignó ${ticket}`,
|
||||
});
|
||||
}
|
||||
for (const userId of watcherIds) {
|
||||
drafts.push({
|
||||
userId,
|
||||
kind: NotificationKind.WATCHED,
|
||||
summary: assignee
|
||||
? `${actor} asignó ${ticket} a ${assignee}`
|
||||
: `${actor} dejó sin asignar ${ticket}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (event instanceof IssueStatusChangedEvent) {
|
||||
const to = event.meta?.toName ?? event.to;
|
||||
@@ -105,6 +126,9 @@ export class NotificationsEventHandler implements IEventHandler<TrackerEvent> {
|
||||
event.meta?.toCategory !== StatusCategory.QA &&
|
||||
event.meta?.toCategory !== StatusCategory.DONE;
|
||||
const reason = event.reason ? `: ${excerpt(event.reason)}` : '';
|
||||
const summary = failedQa
|
||||
? `${actor} devolvió ${ticket} desde QA${reason}`
|
||||
: `${actor} movió ${ticket} a ${to}${reason}`;
|
||||
for (const userId of [assigneeId, reporterId]) {
|
||||
if (!userId) continue;
|
||||
drafts.push({
|
||||
@@ -112,19 +136,37 @@ export class NotificationsEventHandler implements IEventHandler<TrackerEvent> {
|
||||
kind: failedQa
|
||||
? NotificationKind.QA_FAILED
|
||||
: NotificationKind.STATUS_CHANGED,
|
||||
summary: failedQa
|
||||
? `${actor} devolvió ${ticket} desde QA${reason}`
|
||||
: `${actor} movió ${ticket} a ${to}${reason}`,
|
||||
summary,
|
||||
});
|
||||
}
|
||||
for (const userId of watcherIds) {
|
||||
drafts.push({ userId, kind: NotificationKind.WATCHED, summary });
|
||||
}
|
||||
}
|
||||
if (event instanceof CommentAddedEvent) {
|
||||
const said = `«${excerpt(event.comment.body)}»`;
|
||||
// Only members: a mention cannot reach someone who cannot read the ticket.
|
||||
for (const userId of mentionedUserIds(event.comment.body)) {
|
||||
if (!memberIds.has(userId)) continue;
|
||||
drafts.push({
|
||||
userId,
|
||||
kind: NotificationKind.MENTIONED,
|
||||
summary: `${actor} te mencionó en ${issue.key}: ${said}`,
|
||||
});
|
||||
}
|
||||
for (const userId of [assigneeId, reporterId]) {
|
||||
if (!userId) continue;
|
||||
drafts.push({
|
||||
userId,
|
||||
kind: NotificationKind.COMMENTED,
|
||||
summary: `${actor} comentó en ${issue.key}: «${excerpt(event.comment.body)}»`,
|
||||
summary: `${actor} comentó en ${issue.key}: ${said}`,
|
||||
});
|
||||
}
|
||||
for (const userId of watcherIds) {
|
||||
drafts.push({
|
||||
userId,
|
||||
kind: NotificationKind.WATCHED,
|
||||
summary: `${actor} comentó en ${issue.key}, que sigues: ${said}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,13 +54,21 @@ function setup(mailEnabled = false) {
|
||||
const repo = new InMemoryNotifications();
|
||||
const published: NotificationReadModel[] = [];
|
||||
const mailed: string[] = [];
|
||||
const users = new FakeUserDirectory().add('ana').add('bob').add('qa');
|
||||
const users = new FakeUserDirectory()
|
||||
.add('ana')
|
||||
.add('bob')
|
||||
.add('qa')
|
||||
.add('pm');
|
||||
const issue = {
|
||||
id: 'i-1',
|
||||
key: 'WEB-1',
|
||||
title: 'Login roto',
|
||||
reporter: { id: 'qa', name: 'QA' },
|
||||
assignee: { id: 'bob', name: 'Bob' },
|
||||
watchers: [
|
||||
{ id: 'pm', name: 'PM' },
|
||||
{ id: 'bob', name: 'Bob' },
|
||||
],
|
||||
};
|
||||
const notifier = new Notifier(
|
||||
repo,
|
||||
@@ -81,7 +89,15 @@ function setup(mailEnabled = false) {
|
||||
notifier,
|
||||
{ findById: async () => issue } as unknown as FindIssuesRepository,
|
||||
{
|
||||
findById: async () => ({ id: 'p-1', key: 'WEB', name: 'Web' }),
|
||||
findById: async () => ({
|
||||
id: 'p-1',
|
||||
key: 'WEB',
|
||||
name: 'Web',
|
||||
members: ['ana', 'bob', 'qa', 'pm'].map((id) => ({
|
||||
user: { id, name: id },
|
||||
role: ProjectRole.MEMBER,
|
||||
})),
|
||||
}),
|
||||
} as unknown as FindProjectsRepository,
|
||||
users,
|
||||
);
|
||||
@@ -100,8 +116,12 @@ describe('notifications', () => {
|
||||
|
||||
expect(repo.all.map((n) => [n.userId, n.kind])).toEqual([
|
||||
['bob', NotificationKind.ASSIGNED],
|
||||
// Bob follows the ticket too, but is told once: as the assignee.
|
||||
['pm', NotificationKind.WATCHED],
|
||||
['pm', NotificationKind.WATCHED],
|
||||
['bob', NotificationKind.WATCHED],
|
||||
]);
|
||||
expect(published).toHaveLength(1);
|
||||
expect(published).toHaveLength(4);
|
||||
expect(repo.all[0].summary).toContain('WEB-1');
|
||||
});
|
||||
|
||||
@@ -129,6 +149,7 @@ describe('notifications', () => {
|
||||
expect(repo.all.map((n) => [n.userId, n.kind])).toEqual([
|
||||
['bob', NotificationKind.QA_FAILED],
|
||||
['qa', NotificationKind.QA_FAILED],
|
||||
['pm', NotificationKind.WATCHED],
|
||||
]);
|
||||
expect(repo.all[0].summary).toContain('Falla en Safari');
|
||||
});
|
||||
@@ -151,7 +172,32 @@ describe('notifications', () => {
|
||||
await handler.handle(comment);
|
||||
await handler.handle(comment);
|
||||
|
||||
expect(repo.all.map((n) => n.userId)).toEqual(['qa']);
|
||||
expect(repo.all.map((n) => n.userId)).toEqual(['qa', 'pm']);
|
||||
});
|
||||
|
||||
it('tells a mentioned member first, and nobody outside the project', async () => {
|
||||
const { handler, repo } = setup();
|
||||
await handler.handle(
|
||||
at(
|
||||
new CommentAddedEvent('bob', {
|
||||
id: 'c-2',
|
||||
issueId: 'i-1',
|
||||
projectId: 'p-1',
|
||||
authorId: 'bob',
|
||||
body: '@[PM](pm) mira esto, y @[Fuera](stranger) también',
|
||||
deleted: false,
|
||||
createdAt: '2026-10-08T10:00:00.000Z',
|
||||
editedAt: null,
|
||||
}),
|
||||
1,
|
||||
),
|
||||
);
|
||||
|
||||
expect(repo.all.map((n) => [n.userId, n.kind])).toEqual([
|
||||
['pm', NotificationKind.MENTIONED],
|
||||
['qa', NotificationKind.COMMENTED],
|
||||
]);
|
||||
expect(repo.all[0].summary).toContain('«@PM mira esto');
|
||||
});
|
||||
|
||||
it('copies by e-mail only to whoever asked for it, when mail is configured', async () => {
|
||||
|
||||
Reference in New Issue
Block a user