Compare commits
3 Commits
66b09ecf4d
...
3c7c1f7f0e
| Author | SHA1 | Date | |
|---|---|---|---|
| 3c7c1f7f0e | |||
| f986f17968 | |||
| 5ae1d05d30 |
@@ -16,8 +16,11 @@ EVENTS_BRIDGE_ENABLED=true
|
||||
|
||||
# Pireon identity server (https://oauth.pireon.dev/api/documentation)
|
||||
PIREON_BASE_URL=https://oauth.pireon.dev
|
||||
# Secret returned when registering the webhook (POST /api/clients/{client}/webhooks)
|
||||
# Secret returned when registering the webhook (POST /api/clients/{client}/webhooks).
|
||||
# Pireon signs with the secret of the client that registered it: one per client.
|
||||
PIREON_WEBHOOK_SECRET=
|
||||
# The password client's webhook, if it has its own
|
||||
PIREON_PASSWORD_WEBHOOK_SECRET=
|
||||
# OAuth clients of task-stream in Pireon (POST /api/clients). Each Pireon client allows ONE grant type:
|
||||
# authorization_code → "Sign in with Pireon" and Swagger UI. Redirect URIs to register:
|
||||
# http://localhost:4200/auth/callback, http://localhost:3000/api/docs/oauth2-redirect.html
|
||||
|
||||
@@ -344,7 +344,7 @@
|
||||
},
|
||||
"/api/webhooks/pireon": {
|
||||
"post": {
|
||||
"description": "`user.deactivated` closes the user's sessions; `token.revoked` forgets the token. Disabled unless PIREON_WEBHOOK_SECRET is set.",
|
||||
"description": "`user.deactivated` closes the user's sessions; `token.revoked` forgets the token. Disabled unless PIREON_WEBHOOK_SECRET or PIREON_PASSWORD_WEBHOOK_SECRET is set.",
|
||||
"operationId": "PireonWebhookController_receive",
|
||||
"parameters": [
|
||||
{
|
||||
@@ -358,7 +358,7 @@
|
||||
{
|
||||
"name": "X-Webhook-Signature",
|
||||
"in": "header",
|
||||
"description": "HMAC-SHA256 of the raw body with PIREON_WEBHOOK_SECRET (hex)",
|
||||
"description": "HMAC-SHA256 of the raw body with the secret of the Pireon client that registered the webhook (hex)",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
|
||||
@@ -23,6 +23,8 @@ export const configSchema = Joi.object({
|
||||
// Pireon identity server
|
||||
PIREON_BASE_URL: Joi.string().uri().default('https://oauth.pireon.dev'),
|
||||
PIREON_WEBHOOK_SECRET: Joi.string().allow('').optional(),
|
||||
// The password client registers its own webhook, signed with its own secret.
|
||||
PIREON_PASSWORD_WEBHOOK_SECRET: Joi.string().allow('').optional(),
|
||||
// OAuth clients of task-stream in Pireon (each Pireon client allows one grant type):
|
||||
// authorization_code for "Sign in with Pireon" / Swagger UI…
|
||||
PIREON_CLIENT_ID: Joi.string().allow('').optional(),
|
||||
|
||||
@@ -123,7 +123,7 @@ export interface paths {
|
||||
put?: never;
|
||||
/**
|
||||
* Pireon events (called by Pireon, not by apps)
|
||||
* @description `user.deactivated` closes the user's sessions; `token.revoked` forgets the token. Disabled unless PIREON_WEBHOOK_SECRET is set.
|
||||
* @description `user.deactivated` closes the user's sessions; `token.revoked` forgets the token. Disabled unless PIREON_WEBHOOK_SECRET or PIREON_PASSWORD_WEBHOOK_SECRET is set.
|
||||
*/
|
||||
post: operations["PireonWebhookController_receive"];
|
||||
delete?: never;
|
||||
@@ -2825,7 +2825,7 @@ export interface operations {
|
||||
query?: never;
|
||||
header: {
|
||||
"x-webhook-signature": string;
|
||||
/** @description HMAC-SHA256 of the raw body with PIREON_WEBHOOK_SECRET (hex) */
|
||||
/** @description HMAC-SHA256 of the raw body with the secret of the Pireon client that registered the webhook (hex) */
|
||||
"X-Webhook-Signature": string;
|
||||
};
|
||||
path?: never;
|
||||
|
||||
@@ -45,6 +45,7 @@ services:
|
||||
EVENTS_BRIDGE_ENABLED: 'true'
|
||||
PIREON_BASE_URL: https://oauth.pireon.dev
|
||||
PIREON_WEBHOOK_SECRET: ${PIREON_WEBHOOK_SECRET:-}
|
||||
PIREON_PASSWORD_WEBHOOK_SECRET: ${PIREON_PASSWORD_WEBHOOK_SECRET:-}
|
||||
PIREON_CLIENT_ID: ${PIREON_CLIENT_ID:-}
|
||||
PIREON_CLIENT_SECRET: ${PIREON_CLIENT_SECRET:-}
|
||||
# Pireon clients allow one grant type: the one above is authorization_code,
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
| 🗺️ Hoja de ruta | Las épicas en el tiempo (fecha de inicio y límite), con los sprints y las releases como referencias |
|
||||
| 🌐 Web del colectivo | La página de cada proyecto en pireon.dev enseña su hoja de ruta, versiones, sprint y tickets para recién llegados (`good-first-issue`) |
|
||||
| 🧩 Desglose | Épicas → tickets → subtareas (tres niveles), con su progreso; y una checklist dentro de cada ticket para los pasos pequeños |
|
||||
| 👀 Seguir | Cualquier miembro sigue los tickets que le interesan y los ve juntos en «Que sigo»; quien comenta, los sigue solo |
|
||||
| 👀 Seguir | Cualquier miembro sigue los tickets que le interesan y los ve juntos en «Que sigo»; quien lo abre, lo tiene asignado o comenta, lo sigue solo |
|
||||
| 🔔 Avisos | Campana con lo que te concierne (asignaciones, cambios de estado, QA fallido, comentarios, respuestas), en vivo y opcionalmente por correo |
|
||||
| ⏰ Recordatorios | Aviso (y correo, si se quiere) el día antes, el mismo día y al vencer la fecha límite, a quien lo tiene y a quien lo sigue; las tarjetas marcan lo que vence |
|
||||
| 📎 Adjuntos | Capturas, logs y vídeos en el ticket o bajo un comentario; arrastrar, elegir o pegar con Ctrl+V; miniaturas de las imágenes |
|
||||
|
||||
@@ -144,6 +144,7 @@ uno y esperando. Así heredan las garantías del bridge: orden, reintento y nada
|
||||
| `ReleaseArchivedSaga` | `ReleaseArchivedEvent` | `DetachIssueFromReleaseCommand` para sus tickets sin terminar (los terminados se quedan) |
|
||||
| `SprintCarryOverSaga` | `SprintClosedEvent` / `SprintDeletedEvent` | `CarryOverIssueCommand`: lo pendiente pasa al sprint elegido o al backlog; al borrar, todos vuelven al backlog |
|
||||
| `CommentersFollowSaga` | `CommentAddedEvent` | `WatchIssueCommand`: quien comenta pasa a seguir el ticket |
|
||||
| `PeopleFollowSaga` | `IssueCreatedEvent`, `IssueAssignedEvent` | `WatchIssueCommand`: quien abre el ticket y quien lo tiene asignado pasan a seguirlo |
|
||||
| `UserDeactivatedSaga` | `UserDeactivatedEvent` (contexto identity) | `UnassignIssueCommand` para sus tickets abiertos en todos los proyectos, como actor `system` («TaskStream») |
|
||||
|
||||
Reglas que cumplen:
|
||||
|
||||
@@ -251,7 +251,8 @@ Nadie recibe aviso de lo que hizo él mismo, y cada persona recibe uno por event
|
||||
**Menciones**: se escriben `@[Nombre](id-de-usuario)` (la web las inserta al escribir `@`); el id dice a quién avisar y
|
||||
el nombre es lo que se ve, así que renombrar a alguien no rompe comentarios antiguos. Solo avisan a miembros del
|
||||
proyecto. **Seguir**: cualquier miembro, también los lectores, puede seguir un ticket (`watchIssue`/`unwatchIssue`,
|
||||
eventos `IssueWatchedEvent`/`IssueUnwatchedEvent`), y quien comenta pasa a seguirlo (`CommentersFollowSaga`). Quien
|
||||
eventos `IssueWatchedEvent`/`IssueUnwatchedEvent`), y quien lo abre, quien lo tiene asignado y quien comenta pasan a seguirlo (`PeopleFollowSaga`,
|
||||
`CommentersFollowSaga`). Quien
|
||||
sigue un ticket (`WATCHED`) sabe cuándo cambia de estado, se asigna, se comenta, cambia su prioridad o su fecha límite,
|
||||
o entra o sale de un sprint; las ediciones menores (título, etiquetas…) no avisan. «Los que sigo»: `watching` en
|
||||
`myIssues` / `GET /api/me/issues` y en la búsqueda de un proyecto (`issues(filter: { watching: true })`,
|
||||
|
||||
@@ -122,11 +122,16 @@ caduque la caché):
|
||||
1. Registra `https://<tu-api>/api/webhooks/pireon` con los eventos `user.deactivated` y `token.revoked`
|
||||
(`POST /api/clients/{client}/webhooks`).
|
||||
2. Pon el secret que devuelve en `PIREON_WEBHOOK_SECRET`.
|
||||
3. Si usas un cliente aparte para el acceso con contraseña (`PIREON_PASSWORD_CLIENT_ID`), registra el webhook también
|
||||
en ese cliente y pon su secret en `PIREON_PASSWORD_WEBHOOK_SECRET`. Pireon firma cada aviso con el secret del
|
||||
cliente que lo registró, y la revocación de un token solo la avisa el cliente que lo emitió: sin este paso, los
|
||||
tokens de quien entra con contraseña no se olvidan al revocarlos.
|
||||
|
||||
La API verifica la firma `X-Webhook-Signature` (HMAC-SHA256 del cuerpo), marca al usuario como inactivo y borra sus
|
||||
La API verifica la firma `X-Webhook-Signature` (HMAC-SHA256 del cuerpo, válida con cualquiera de los dos secrets),
|
||||
marca al usuario como inactivo y borra sus
|
||||
sesiones cacheadas. Además guarda `UserDeactivatedEvent` en el event store, y una saga del tracker deja sin asignar sus
|
||||
tickets abiertos en todos los proyectos (ver [Arquitectura](02-arquitectura.md)). Sin `PIREON_WEBHOOK_SECRET`, el
|
||||
endpoint rechaza todas las llamadas.
|
||||
tickets abiertos en todos los proyectos (ver [Arquitectura](02-arquitectura.md)). Repetir un aviso (llega uno por
|
||||
cliente) no tiene efecto. Sin ningún secret, el endpoint rechaza todas las llamadas.
|
||||
|
||||
## 5.4 Usuarios en TaskStream
|
||||
|
||||
|
||||
@@ -87,6 +87,7 @@ donde los lee Compose.
|
||||
| `PIREON_SCOPES` | `users:read` | Scopes que se piden al iniciar sesión |
|
||||
| `PIREON_PASSWORD_GRANT` | `true` | Permite el login con email y contraseña |
|
||||
| `PIREON_WEBHOOK_SECRET` | — | Activa y firma los webhooks de Pireon |
|
||||
| `PIREON_PASSWORD_WEBHOOK_SECRET` | — | El del webhook del cliente de contraseña, si es otro cliente |
|
||||
| `PIREON_TIMEOUT_MS` | `5000` | Timeout de las llamadas a Pireon |
|
||||
| `AUTH_SESSION_TTL_SECONDS` | `60` | Cuánto se confía en un token validado antes de volver a preguntar a Pireon |
|
||||
| `GRAPHQL_PLAYGROUND` | `true` | GraphiQL e introspección |
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,48 @@
|
||||
import { UnauthorizedException } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { IdentityService } from '@task-stream/api/identity/application';
|
||||
import { createHmac } from 'node:crypto';
|
||||
import { PireonWebhookController } from './pireon-webhook.controller';
|
||||
|
||||
describe('PireonWebhookController', () => {
|
||||
const handled: string[] = [];
|
||||
const controller = (secrets: Record<string, string>) =>
|
||||
new PireonWebhookController(
|
||||
{
|
||||
handleWebhook: async (event: string) => {
|
||||
handled.push(event);
|
||||
},
|
||||
} as unknown as IdentityService,
|
||||
{ get: (key: string) => secrets[key] } as unknown as ConfigService,
|
||||
);
|
||||
const body = { event: 'token.revoked', data: { token_id: 't-1' } };
|
||||
const raw = Buffer.from(JSON.stringify(body));
|
||||
const signed = (secret: string) =>
|
||||
createHmac('sha256', secret).update(raw).digest('hex');
|
||||
const request = { rawBody: raw, body } as never;
|
||||
|
||||
beforeEach(() => (handled.length = 0));
|
||||
|
||||
it("accepts a delivery signed with either Pireon client's secret", async () => {
|
||||
const both = controller({
|
||||
PIREON_WEBHOOK_SECRET: 'web-secret',
|
||||
PIREON_PASSWORD_WEBHOOK_SECRET: 'password-secret',
|
||||
});
|
||||
await both.receive(request, signed('web-secret'));
|
||||
await both.receive(request, `sha256=${signed('password-secret')}`);
|
||||
expect(handled).toEqual(['token.revoked', 'token.revoked']);
|
||||
});
|
||||
|
||||
it('refuses any other signature, and everything when no secret is set', async () => {
|
||||
await expect(
|
||||
controller({ PIREON_WEBHOOK_SECRET: 'web-secret' }).receive(
|
||||
request,
|
||||
signed('password-secret'),
|
||||
),
|
||||
).rejects.toThrow(UnauthorizedException);
|
||||
await expect(
|
||||
controller({}).receive(request, signed('web-secret')),
|
||||
).rejects.toThrow(/disabled/);
|
||||
expect(handled).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -21,7 +21,13 @@ import { Request } from 'express';
|
||||
import { createHmac, timingSafeEqual } from 'node:crypto';
|
||||
import { Public } from '../auth/auth.decorators';
|
||||
|
||||
/** Receives `user.deactivated` / `token.revoked` from Pireon. */
|
||||
/**
|
||||
* Receives `user.deactivated` / `token.revoked` from Pireon.
|
||||
*
|
||||
* Pireon signs each webhook with the secret of the client that registered
|
||||
* it, and this API has two (the web app's and the password grant's): a
|
||||
* delivery is good if it verifies with either.
|
||||
*/
|
||||
@ApiTags('Webhooks')
|
||||
@Controller('webhooks/pireon')
|
||||
export class PireonWebhookController {
|
||||
@@ -37,11 +43,12 @@ export class PireonWebhookController {
|
||||
summary: 'Pireon events (called by Pireon, not by apps)',
|
||||
description:
|
||||
"`user.deactivated` closes the user's sessions; `token.revoked` forgets the token. " +
|
||||
'Disabled unless PIREON_WEBHOOK_SECRET is set.',
|
||||
'Disabled unless PIREON_WEBHOOK_SECRET or PIREON_PASSWORD_WEBHOOK_SECRET is set.',
|
||||
})
|
||||
@ApiHeader({
|
||||
name: 'X-Webhook-Signature',
|
||||
description: 'HMAC-SHA256 of the raw body with PIREON_WEBHOOK_SECRET (hex)',
|
||||
description:
|
||||
'HMAC-SHA256 of the raw body with the secret of the Pireon client that registered the webhook (hex)',
|
||||
required: true,
|
||||
})
|
||||
@ApiNoContentResponse({ description: 'Processed' })
|
||||
@@ -63,13 +70,16 @@ export class PireonWebhookController {
|
||||
await this.identity.handleWebhook(event, data);
|
||||
}
|
||||
|
||||
/** X-Webhook-Signature = HMAC-SHA256(raw body, PIREON_WEBHOOK_SECRET). */
|
||||
/** X-Webhook-Signature = HMAC-SHA256(raw body, the client's webhook secret). */
|
||||
private verifySignature(
|
||||
rawBody: Buffer | undefined,
|
||||
signature: string | undefined,
|
||||
): void {
|
||||
const secret = this.config.get<string>('PIREON_WEBHOOK_SECRET');
|
||||
if (!secret) {
|
||||
const secrets = [
|
||||
this.config.get<string>('PIREON_WEBHOOK_SECRET'),
|
||||
this.config.get<string>('PIREON_PASSWORD_WEBHOOK_SECRET'),
|
||||
].filter((secret): secret is string => !!secret);
|
||||
if (!secrets.length) {
|
||||
throw new UnauthorizedException(
|
||||
'Webhooks are disabled (PIREON_WEBHOOK_SECRET not set)',
|
||||
);
|
||||
@@ -77,11 +87,16 @@ export class PireonWebhookController {
|
||||
if (!rawBody || !signature)
|
||||
throw new UnauthorizedException('Missing signature');
|
||||
|
||||
const expected = createHmac('sha256', secret).update(rawBody).digest('hex');
|
||||
const received = signature.replace(/^sha256=/, '');
|
||||
const valid =
|
||||
received.length === expected.length &&
|
||||
timingSafeEqual(Buffer.from(received), Buffer.from(expected));
|
||||
const received = Buffer.from(signature.replace(/^sha256=/, ''));
|
||||
const valid = secrets.some((secret) => {
|
||||
const expected = Buffer.from(
|
||||
createHmac('sha256', secret).update(rawBody).digest('hex'),
|
||||
);
|
||||
return (
|
||||
received.length === expected.length &&
|
||||
timingSafeEqual(received, expected)
|
||||
);
|
||||
});
|
||||
if (!valid) throw new UnauthorizedException('Invalid signature');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import {
|
||||
IssueAssignedEvent,
|
||||
IssueCreatedEvent,
|
||||
IssuePriority,
|
||||
} from '@task-stream/api/tracker/domain';
|
||||
import { lastValueFrom, of, toArray } from 'rxjs';
|
||||
import { WatchIssueCommand } from '../commands/watch-issue.command';
|
||||
import { PeopleFollowSaga } from './people-follow.saga';
|
||||
|
||||
describe('PeopleFollowSaga', () => {
|
||||
const saga = new PeopleFollowSaga();
|
||||
const created = (assigneeId: string | null) =>
|
||||
new IssueCreatedEvent('ana', {
|
||||
id: 'i-1',
|
||||
projectId: 'p-1',
|
||||
reporterId: 'ana',
|
||||
assigneeId,
|
||||
priority: IssuePriority.MEDIUM,
|
||||
} as never);
|
||||
|
||||
it('makes whoever opens a ticket, and whoever gets it, follow it', async () => {
|
||||
expect(
|
||||
await lastValueFrom(saga.created(of(created('bob'))).pipe(toArray())),
|
||||
).toEqual([
|
||||
new WatchIssueCommand('ana', 'i-1', 'ana'),
|
||||
new WatchIssueCommand('bob', 'i-1', 'bob'),
|
||||
]);
|
||||
// Opening a ticket for oneself: once.
|
||||
expect(
|
||||
await lastValueFrom(saga.created(of(created('ana'))).pipe(toArray())),
|
||||
).toEqual([new WatchIssueCommand('ana', 'i-1', 'ana')]);
|
||||
});
|
||||
|
||||
it('makes whoever a ticket is assigned to follow it, not when unassigned', async () => {
|
||||
const commands = await lastValueFrom(
|
||||
saga
|
||||
.assigned(
|
||||
of(
|
||||
new IssueAssignedEvent('ana', 'i-1', 'p-1', 'bob', null),
|
||||
new IssueAssignedEvent('ana', 'i-1', 'p-1', null, 'bob'),
|
||||
),
|
||||
)
|
||||
.pipe(toArray()),
|
||||
);
|
||||
expect(commands).toEqual([new WatchIssueCommand('bob', 'i-1', 'bob')]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { ICommand, IEvent, ofType, Saga } from '@nestjs/cqrs';
|
||||
import {
|
||||
IssueAssignedEvent,
|
||||
IssueCreatedEvent,
|
||||
} from '@task-stream/api/tracker/domain';
|
||||
import { filter, mergeMap, Observable } from 'rxjs';
|
||||
import { WatchIssueCommand } from '../commands/watch-issue.command';
|
||||
|
||||
/** Follows a ticket as `userId` would by pressing "Seguir". */
|
||||
const follow = (userId: string, issueId: string) =>
|
||||
new WatchIssueCommand(userId, issueId, userId);
|
||||
|
||||
/**
|
||||
* Whoever opens a ticket and whoever it is assigned to follow it from then
|
||||
* on, as a commenter does: its changes concern them. Unfollowing stays
|
||||
* theirs to do. (Idempotent: following twice changes nothing; someone who
|
||||
* is not a member, like the system, is refused and skipped.)
|
||||
*/
|
||||
@Injectable()
|
||||
export class PeopleFollowSaga {
|
||||
@Saga()
|
||||
created = (events$: Observable<IEvent>): Observable<ICommand> =>
|
||||
events$.pipe(
|
||||
ofType(IssueCreatedEvent),
|
||||
mergeMap(({ issue }) =>
|
||||
[...new Set([issue.reporterId, issue.assigneeId])]
|
||||
.filter((userId): userId is string => !!userId)
|
||||
.map((userId) => follow(userId, issue.id)),
|
||||
),
|
||||
);
|
||||
|
||||
@Saga()
|
||||
assigned = (events$: Observable<IEvent>): Observable<ICommand> =>
|
||||
events$.pipe(
|
||||
ofType(IssueAssignedEvent),
|
||||
filter((event) => !!event.assigneeId),
|
||||
mergeMap((event) => [
|
||||
follow(event.assigneeId as string, event.aggregateId),
|
||||
]),
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { ProjectDeletionSaga } from './project-deletion.saga';
|
||||
import { ReleaseArchivedSaga } from './release-archived.saga';
|
||||
import { UserDeactivatedSaga } from './user-deactivated.saga';
|
||||
import { CommentersFollowSaga } from './commenters-follow.saga';
|
||||
import { PeopleFollowSaga } from './people-follow.saga';
|
||||
import { SprintCarryOverSaga } from './sprint-carry-over.saga';
|
||||
|
||||
/** Reactions to events that change other aggregates (run by the events bridge). */
|
||||
@@ -10,5 +11,6 @@ export const SAGAS = [
|
||||
ReleaseArchivedSaga,
|
||||
UserDeactivatedSaga,
|
||||
CommentersFollowSaga,
|
||||
PeopleFollowSaga,
|
||||
SprintCarryOverSaga,
|
||||
];
|
||||
|
||||
@@ -608,9 +608,33 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
|
||||
novalidate
|
||||
>
|
||||
<pi-textarea
|
||||
[placeholder]="'Responde a ' + thread.comment.author.name + '…'"
|
||||
[placeholder]="
|
||||
'Responde a ' +
|
||||
thread.comment.author.name +
|
||||
'… Escribe @ para mencionar a alguien.'
|
||||
"
|
||||
[(value)]="replyBody"
|
||||
/>
|
||||
@if (replyMentionOptions().length) {
|
||||
<div
|
||||
class="ts-mentions"
|
||||
role="group"
|
||||
aria-label="Mencionar a"
|
||||
>
|
||||
@for (member of replyMentionOptions(); track member.id) {
|
||||
<button
|
||||
type="button"
|
||||
class="ts-mentions__option"
|
||||
(click)="mentionInReply(member)"
|
||||
>
|
||||
<span class="ts-comment__avatar" aria-hidden="true">{{
|
||||
initialsOf(member.name)
|
||||
}}</span>
|
||||
{{ member.name }}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
<div class="ts-reply__actions">
|
||||
<pi-action-button
|
||||
color="primary"
|
||||
@@ -1024,17 +1048,13 @@ export class IssuePage {
|
||||
protected readonly commentFiles = signal<File[]>([]);
|
||||
protected readonly segments = mentionSegments;
|
||||
/** Members matching what is typed after an `@` at the end of the comment. */
|
||||
protected readonly mentionOptions = computed(() => {
|
||||
const query = mentionQuery(this.commentBody());
|
||||
if (query === null) return [];
|
||||
return this.project
|
||||
.members()
|
||||
.map((m) => m.user)
|
||||
.filter(
|
||||
(user) => user.id !== this.facade.me() && matches(user.name, query),
|
||||
)
|
||||
.slice(0, 6);
|
||||
});
|
||||
protected readonly mentionOptions = computed(() =>
|
||||
this.membersMatching(this.commentBody()),
|
||||
);
|
||||
/** The same, for the answer being written. */
|
||||
protected readonly replyMentionOptions = computed(() =>
|
||||
this.membersMatching(this.replyBody()),
|
||||
);
|
||||
/** Pull requests first, then the newest. */
|
||||
protected readonly devLinks = computed(() =>
|
||||
[...(this.facade.issue()?.devLinks ?? [])].sort(
|
||||
@@ -1226,6 +1246,23 @@ export class IssuePage {
|
||||
this.replyingTo.set(null);
|
||||
}
|
||||
|
||||
/** Members whose name fits the `@…` being typed at the end of `text`. */
|
||||
private membersMatching(text: string) {
|
||||
const query = mentionQuery(text);
|
||||
if (query === null) return [];
|
||||
return this.project
|
||||
.members()
|
||||
.map((m) => m.user)
|
||||
.filter(
|
||||
(user) => user.id !== this.facade.me() && matches(user.name, query),
|
||||
)
|
||||
.slice(0, 6);
|
||||
}
|
||||
|
||||
protected mentionInReply(user: { id: string; name: string }): void {
|
||||
this.replyBody.update((text) => insertMention(text, user));
|
||||
}
|
||||
|
||||
protected mention(user: { id: string; name: string }): void {
|
||||
this.commentBody.update((text) => insertMention(text, user));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user