Files
task-stream/libs/api/tracker/application/src/lib/event-handlers/activity-summary.ts
Josep Piguillem 93d028e870 feat: ask a teammate to review a ticket
«Pedir revisión» on a ticket asks a member (with an optional note): they
are notified and see it under «Te piden revisión» in my work until they
answer «Todo bien» or «Pedir cambios» (with what to change) from the
ticket's Revisiones box; whoever asked hears the answer and can withdraw
an open request. Events of the ticket (requested, answered, cancelled),
so they show in its history and the activity feed. Viewers can be asked
and answer; not oneself, nor twice the same person with one pending.

API: requestReview / answerReview / cancelReview, Issue.reviews,
myIssues(reviewing); POST /api/issues/{issue}/reviews,
POST …/reviews/{id}/answer, DELETE …/reviews/{id}. MCP: request_review,
answer_review, my_issues reviewing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 20:38:30 +02:00

261 lines
9.7 KiB
TypeScript

import {
CommentAddedEvent,
CommentDeletedEvent,
CommentEditedEvent,
IssueAssignedEvent,
IssueAttachmentAddedEvent,
IssueAttachmentRemovedEvent,
IssueChecklistItemAddedEvent,
IssueChecklistItemRemovedEvent,
IssueChecklistItemUpdatedEvent,
IssueParentChangedEvent,
IssueReviewAnsweredEvent,
IssueReviewCancelledEvent,
IssueReviewRequestedEvent,
ReviewVerdict,
IssueDevLinkRecordedEvent,
IssueTrashedEvent,
IssueRestoredEvent,
IssueCreatedEvent,
IssueLinkedEvent,
IssueLinkType,
IssueReleaseChangedEvent,
IssueStatusChangedEvent,
IssueUnlinkedEvent,
IssueUpdatedEvent,
ProjectCreatedEvent,
ProjectMemberAddedEvent,
ProjectMemberRemovedEvent,
ProjectMemberRoleChangedEvent,
ProjectRole,
ProjectSettingsChangedEvent,
ProjectUpdatedEvent,
ProjectArchivedEvent,
ProjectUnarchivedEvent,
ProjectWorkflow,
ReleaseArchivedEvent,
ReleaseNotesWrittenEvent,
ReleaseCreatedEvent,
ReleaseShippedEvent,
ReleaseUpdatedEvent,
IssueSprintChangedEvent,
SprintClosedEvent,
SprintCreatedEvent,
SprintDeletedEvent,
SprintStartedEvent,
SprintUpdatedEvent,
TrackerEvent,
} from '@task-stream/api/tracker/domain';
export interface SummaryContext {
actor: string;
user: (id: string | null) => string;
issueKey: string;
projectKey: string;
releaseName: string;
/** '' when unknown (e.g. the sprint was deleted). */
sprintName: string;
/** The project's workflow: names of statuses and types. */
workflow: ProjectWorkflow;
}
/* The activity feed is read by people: Spanish, with the same labels as the web app. */
/** Default types keep their lowercase wording in sentences ("creó WEB-3 (tarea)"). */
const DEFAULT_TYPE_WORDING: Record<string, string> = {
BUG: 'bug',
FEATURE: 'feature',
UI_CHANGE: 'cambio de UI',
TASK: 'tarea',
QA: 'QA',
};
const LINK: Record<IssueLinkType, string> = {
[IssueLinkType.BLOCKS]: 'bloquea a',
[IssueLinkType.DEPENDS_ON]: 'depende de',
[IssueLinkType.TESTS]: 'prueba',
[IssueLinkType.DUPLICATES]: 'duplica a',
[IssueLinkType.RELATES_TO]: 'está relacionado con',
};
const ROLE: Record<ProjectRole, string> = {
[ProjectRole.OWNER]: 'propietario',
[ProjectRole.ADMIN]: 'admin',
[ProjectRole.MEMBER]: 'miembro',
[ProjectRole.VIEWER]: 'lector',
[ProjectRole.GUEST]: 'invitado',
};
const FIELD: Record<string, string> = {
title: 'el título',
description: 'la descripción',
type: 'el tipo',
area: 'el área',
team: 'el equipo',
priority: 'la prioridad',
labels: 'las etiquetas',
figmaUrl: 'el enlace de Figma',
storyPoints: 'los puntos',
startDate: 'la fecha de inicio',
dueDate: 'la fecha límite',
customFields: 'los campos personalizados',
};
const list = (items: string[]) =>
items.length <= 1
? items.join('')
: `${items.slice(0, -1).join(', ')} y ${items[items.length - 1]}`;
/** One human-readable line per event, for the project activity feed. */
export function describeEvent(
event: TrackerEvent,
ctx: SummaryContext,
): string {
const { actor, issueKey } = ctx;
if (event instanceof ProjectCreatedEvent)
return `${actor} creó el proyecto ${event.project.key}`;
if (event instanceof ProjectUpdatedEvent)
return `${actor} actualizó el proyecto ${ctx.projectKey}`;
if (event instanceof ProjectArchivedEvent)
return `${actor} archivó el proyecto ${ctx.projectKey}`;
if (event instanceof ProjectUnarchivedEvent)
return `${actor} desarchivó el proyecto ${ctx.projectKey}`;
if (event instanceof ProjectSettingsChangedEvent)
return `${actor} cambió los estados, tipos o áreas del proyecto ${ctx.projectKey}`;
if (event instanceof ProjectMemberAddedEvent) {
return `${actor} añadió a ${ctx.user(event.userId)} como ${ROLE[event.role]}`;
}
if (event instanceof ProjectMemberRemovedEvent) {
return event.userId === event.actorId
? `${actor} salió del proyecto`
: `${actor} quitó a ${ctx.user(event.userId)} del proyecto`;
}
if (event instanceof ProjectMemberRoleChangedEvent) {
return `${actor} cambió el rol de ${ctx.user(event.userId)} a ${ROLE[event.role]}`;
}
if (event instanceof IssueCreatedEvent) {
const type =
DEFAULT_TYPE_WORDING[event.issue.type] ??
ctx.workflow.typeName(event.issue.type);
return `${actor} creó ${event.issue.key} (${type}): ${event.issue.title}`;
}
if (event instanceof IssueUpdatedEvent) {
return `${actor} cambió ${list(Object.keys(event.changes).map((f) => FIELD[f] ?? f))} de ${issueKey}`;
}
if (event instanceof IssueStatusChangedEvent) {
// Names as they were then (old events: as configured now).
const from = event.meta?.fromName ?? ctx.workflow.statusName(event.from);
const to = event.meta?.toName ?? ctx.workflow.statusName(event.to);
return `${actor} movió ${issueKey} de ${from} a ${to}${event.reason ? `: ${event.reason}` : ''}`;
}
if (event instanceof IssueAssignedEvent) {
return event.assigneeId
? `${actor} asignó ${issueKey} a ${ctx.user(event.assigneeId)}`
: `${actor} dejó ${issueKey} sin asignar`;
}
if (event instanceof IssueLinkedEvent) {
return `${actor} indicó que ${issueKey} ${LINK[event.link.type]} ${event.link.targetKey}`;
}
if (event instanceof IssueUnlinkedEvent) {
return `${actor} quitó la relación entre ${issueKey} y ${event.link.targetKey}`;
}
if (event instanceof IssueReleaseChangedEvent) {
return event.releaseId
? `${actor} metió ${issueKey} en la release ${ctx.releaseName}`
: `${actor} sacó ${issueKey} de su release`;
}
if (event instanceof IssueAttachmentAddedEvent)
return `${actor} adjuntó ${event.attachment.fileName} a ${issueKey}`;
if (event instanceof IssueAttachmentRemovedEvent)
return `${actor} quitó el adjunto ${event.fileName} de ${issueKey}`;
if (event instanceof IssueSprintChangedEvent) {
const sprint = ctx.sprintName ? `el sprint ${ctx.sprintName}` : 'un sprint';
return event.sprintId
? `${actor} metió ${issueKey} en ${sprint}`
: `${actor} sacó ${issueKey} de ${sprint}`;
}
if (event instanceof IssueTrashedEvent)
return `${actor} movió ${issueKey} a la papelera`;
if (event instanceof IssueRestoredEvent)
return `${actor} recuperó ${issueKey} de la papelera`;
if (event instanceof IssueDevLinkRecordedEvent) {
const { kind, ref, state, author } = event.link;
const who = author ? ` (${author})` : '';
if (kind === 'COMMIT') return `Commit ${ref} sobre ${issueKey}${who}`;
if (kind === 'BRANCH') return `Rama ${ref} para ${issueKey}${who}`;
const verb =
state === 'MERGED'
? 'fusionada'
: state === 'CLOSED'
? 'cerrada'
: 'abierta';
return `Pull request ${ref} ${verb} para ${issueKey}${who}`;
}
if (event instanceof IssueReviewRequestedEvent)
return `${actor} pidió a ${ctx.user(event.reviewerId)} que revise ${issueKey}`;
if (event instanceof IssueReviewAnsweredEvent)
return event.verdict === ReviewVerdict.APPROVED
? `${actor} dio por bueno ${issueKey} al revisarlo`
: `${actor} pidió cambios en ${issueKey} al revisarlo`;
if (event instanceof IssueReviewCancelledEvent)
return `${actor} retiró una petición de revisión 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 event.comment.replyTo
? `${actor} respondió a un comentario en ${issueKey}`
: `${actor} comentó en ${issueKey}`;
if (event instanceof CommentEditedEvent)
return `${actor} editó un comentario en ${issueKey}`;
if (event instanceof CommentDeletedEvent)
return `${actor} borró un comentario en ${issueKey}`;
if (event instanceof ReleaseCreatedEvent)
return `${actor} creó la release ${event.release.name}`;
if (event instanceof ReleaseUpdatedEvent)
return `${actor} actualizó la release ${ctx.releaseName}`;
if (event instanceof ReleaseShippedEvent)
return `${actor} publicó la release ${ctx.releaseName} 🚀`;
if (event instanceof ReleaseNotesWrittenEvent)
return event.notes
? `${actor} escribió las notas de la release ${ctx.releaseName}`
: `${actor} quitó las notas de la release ${ctx.releaseName}`;
if (event instanceof ReleaseArchivedEvent)
return `${actor} archivó la release ${ctx.releaseName}`;
const sprint = ctx.sprintName || 'un sprint';
if (event instanceof SprintCreatedEvent)
return `${actor} planificó el sprint ${event.sprint.name}`;
if (event instanceof SprintUpdatedEvent)
return `${actor} actualizó el sprint ${sprint}`;
if (event instanceof SprintStartedEvent)
return `${actor} empezó el sprint ${sprint} 🏁`;
if (event instanceof SprintClosedEvent) {
const left = event.unfinishedIssues
? `; ${event.unfinishedIssues} ticket(s) sin terminar pasan ${event.carryOverTo ? 'al siguiente sprint' : 'al backlog'}`
: '';
return `${actor} cerró el sprint ${sprint}${left}`;
}
if (event instanceof SprintDeletedEvent)
return `${actor} borró el sprint ${sprint}`;
return `${actor}: ${event.constructor.name}`;
}