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:
2026-10-08 09:41:32 +02:00
parent efa06a0723
commit 88928b402f
49 changed files with 1318 additions and 41 deletions

View File

@@ -0,0 +1,30 @@
import {
insertMention,
matches,
mentionQuery,
mentionSegments,
} from './mentions';
describe('mentions', () => {
it('splits a text into words and mentions', () => {
expect(mentionSegments('Hola @[Ana](u1), ¿lo miras?')).toEqual([
{ text: 'Hola ' },
{ mention: 'Ana', userId: 'u1' },
{ text: ', ¿lo miras?' },
]);
});
it('notices an @ being typed and completes it', () => {
expect(mentionQuery('Pregunta a @an')).toBe('an');
expect(mentionQuery('Pregunta a @')).toBe('');
expect(mentionQuery('correo@dominio')).toBeNull();
expect(
insertMention('Pregunta a @an', { id: 'u1', name: 'Ana García' }),
).toBe('Pregunta a @[Ana García](u1) ');
});
it('finds people ignoring accents and case', () => {
expect(matches('José Pérez', 'jose pe')).toBe(true);
expect(matches('Ana', 'bob')).toBe(false);
});
});

View File

@@ -0,0 +1,52 @@
/**
* Mentions are stored as `@[Ana García](<user id>)` — the API reads the id to
* tell that person; people see `@Ana García`.
*/
const MENTION = /@\[([^\]\n]{1,80})\]\(([\w-]{1,64})\)/g;
export type TextSegment =
| { text: string }
| { mention: string; userId: string };
/** The text in pieces, to show mentions highlighted. */
export function mentionSegments(text: string): TextSegment[] {
const segments: TextSegment[] = [];
let last = 0;
for (const match of text.matchAll(MENTION)) {
const at = match.index ?? 0;
if (at > last) segments.push({ text: text.slice(last, at) });
segments.push({ mention: match[1], userId: match[2] });
last = at + match[0].length;
}
if (last < text.length) segments.push({ text: text.slice(last) });
return segments;
}
const TYPING = /(^|\s)@([^\s@[\]()]{0,30})$/u;
/** What is being typed after an `@` at the end of the text, or null. */
export function mentionQuery(text: string): string | null {
return TYPING.exec(text)?.[2] ?? null;
}
/** Replaces the `@what-was-typed` at the end with the mention. */
export function insertMention(
text: string,
user: { id: string; name: string },
): string {
const name = user.name.replace(/[[\]()\n]/g, ' ').trim();
return text.replace(
TYPING,
(_, before: string) => `${before}@[${name}](${user.id}) `,
);
}
/** Case- and accent-insensitive "contains". */
export function matches(name: string, query: string): boolean {
const fold = (s: string) =>
s
.normalize('NFD')
.replace(/\p{Diacritic}/gu, '')
.toLowerCase();
return fold(name).includes(fold(query));
}

View File

@@ -86,6 +86,8 @@ export interface Issue extends IssueSummary {
dueDate: string | null;
links: IssueLink[];
attachments: Attachment[];
/** People following the ticket. */
watchers: UserRef[];
allowedTransitions: string[];
createdAt: string;
}

View File

@@ -87,6 +87,8 @@ export abstract class IssuesGateway {
issueId: string,
releaseId: string | null,
): Observable<CommandResult>;
/** The signed-in person follows (or stops following) the ticket. */
abstract watch(issueId: string, watching: boolean): Observable<CommandResult>;
}
export abstract class CommentsGateway {