«Seguir todo» on a ticket with subtasks (and on each epic of the Epics
page) tells you of it and of every ticket hanging from it, at any depth,
also the ones added later: their moves, assignments, comments and plan
changes, as a follower of each, plus when a ticket is placed under it.
Guests of a private project are left out. Personal, no history (Mongo
`tree_follows`).
API: followTree / unfollowTree, Issue.followingTree, PUT/DELETE
/api/issues/{issue}/follow-tree. MCP: watch_issue with tree.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
799 lines
77 KiB
Markdown
799 lines
77 KiB
Markdown
# 3. API
|
|
|
|
API GraphQL en `/graphql` (HTTP para queries y mutations, WebSocket `graphql-ws` para subscriptions) más unos pocos
|
|
endpoints REST bajo `/api`. El esquema completo se genera en `apps/api/schema.gql`.
|
|
|
|
## 3.1 Modelo de dominio (contexto `tracker`)
|
|
|
|
```mermaid
|
|
classDiagram
|
|
class Project {
|
|
key: "TS"
|
|
name
|
|
members: (userId, role)[]
|
|
settings: estados · transiciones · tipos · áreas
|
|
addMember() changeMemberRole() removeMember() updateDetails() updateSettings()
|
|
}
|
|
class Issue {
|
|
key: "TS-42"
|
|
type · area · priority · status
|
|
assigneeId · releaseId · labels · figmaUrl
|
|
links: (type, target)[]
|
|
changeStatus() assign() linkTo() unlink() setRelease() updateDetails()
|
|
}
|
|
class Comment {
|
|
body · authorId
|
|
edit() delete()
|
|
}
|
|
class Release {
|
|
name · status · targetDate
|
|
update() ship() archive()
|
|
}
|
|
Project "1" --> "*" Issue
|
|
Issue "1" --> "*" Comment
|
|
Project "1" --> "*" Release
|
|
Release "0..1" <-- "*" Issue
|
|
Issue "*" --> "*" Issue : links
|
|
```
|
|
|
|
### Roles por proyecto
|
|
|
|
| Rol | Ver | Crear/modificar tickets, comentar | Gestionar miembros y releases |
|
|
| -------------------------------- | ---------------- | ----------------------------------------------- | -------------------------------------------- |
|
|
| `OWNER` (quien crea el proyecto) | ✅ | ✅ | ✅ (no se le puede quitar ni cambiar el rol) |
|
|
| `ADMIN` | ✅ | ✅ | ✅ |
|
|
| `MEMBER` | ✅ | ✅ | ❌ |
|
|
| `VIEWER` | ✅ | ❌ | ❌ |
|
|
| `GUEST` | Solo sus tickets | Abrir tickets, comentar y adjuntar en los suyos | ❌ |
|
|
|
|
Un **invitado** (`GUEST`, para clientes o gente de fuera) de un proyecto privado solo ve los tickets en los que
|
|
participa: los que abrió, los que tiene asignados y los que sigue. El resto no existen para él (`404`, también en
|
|
«Mi trabajo», en las suscripciones en tiempo real, en «bloqueado por» y en los enlazados), y las vistas de todo el
|
|
proyecto (actividad, estadísticas, panel, releases, sprints, épicas, papelera) le responden `403`. Abre tickets sin
|
|
asignarlos ni planificarlos, comenta y adjunta en los suyos; no cambia su estado ni sus campos. En un proyecto público
|
|
ve lo mismo que cualquiera.
|
|
|
|
Cualquiera puede salir de un proyecto (salvo el propietario). Los no miembros reciben _not found_, para no revelar que
|
|
el proyecto existe.
|
|
|
|
### Proyectos públicos
|
|
|
|
Con `isPublic` (al crear, o después con `updateProject` / `PATCH /api/projects/{project}`, propietario o admin),
|
|
**cualquiera puede leer** el proyecto, también sin token: el proyecto y sus miembros, tablero, tickets, comentarios,
|
|
historial, releases (progreso y burnup), actividad, seguimiento y estadísticas. Escribir sigue siendo cosa de los
|
|
miembros según su rol (sin token: 401; con token pero sin ser miembro: 403). Un usuario con sesión que no es miembro
|
|
lee igual que un anónimo. Las subscriptions exigen sesión; con ella, también se pueden seguir proyectos públicos.
|
|
|
|
**Propuestas del público**: un proyecto público puede aceptar errores e ideas de cualquiera, sin cuenta
|
|
(`acceptsProposals`, en `updateProject` / `PATCH`). `POST /api/explore/projects/{project}/proposals` (`kind`: `BUG` o
|
|
`IDEA`, `title`, `description`, `author` opcional) crea un ticket con la etiqueta `propuesta`, a nombre de TaskStream,
|
|
en el primer estado, de tipo Bug o Feature (o el primer tipo activo). No se pide correo, porque quedaría a la vista. Contra
|
|
el spam, el campo `website` es una trampa (si llega relleno se responde como si nada y no se guarda) y cada dirección
|
|
envía como mucho 5 cada 15 minutos (429); el contador vive en Redis (`RateLimitStore`), así que vale igual con
|
|
varias instancias. Los propietarios y admins del proyecto reciben un aviso (`PROPOSED`). La web tiene el formulario en `/p/{CLAVE}/propose`, enlazado en la cabecera del proyecto.
|
|
|
|
**Votos**: quien tenga sesión vota los tickets de un proyecto público, y los miembros los de uno privado
|
|
(`voteIssue(issueId, on)`, `PUT/DELETE /api/issues/{issue}/vote`). Cada ticket da `votes` y `voted` (si he votado
|
|
yo; en REST, solo con token); la lista de quién votó no se publica.
|
|
|
|
`publicProjects` (GraphQL) y `GET /api/explore/projects` (REST, sin token) los listan. Al volver a privado, quien no es
|
|
miembro vuelve a recibir _not found_. Los enlaces a tickets de otros proyectos solo se muestran si el visitante puede
|
|
leer esos proyectos.
|
|
|
|
### Borrar un proyecto
|
|
|
|
Solo el **propietario**, escribiendo la clave del proyecto para confirmar (`deleteProject(projectId, confirmKey)` o
|
|
`DELETE /api/projects/{project}?confirm=CLAVE`). El proyecto desaparece al momento y, a continuación, una saga borra
|
|
sus tickets (y sus comentarios), sus releases y los enlaces que tickets de otros proyectos tenían hacia los suyos (ver
|
|
[Arquitectura](02-arquitectura.md)). La clave queda libre para un proyecto nuevo. Los eventos se quedan en el event
|
|
store como auditoría, pero desde la API no se puede deshacer.
|
|
|
|
### Archivar un proyecto
|
|
|
|
El propietario archiva un proyecto terminado (`archiveProject` / `POST /api/projects/{project}/archive`) en lugar de
|
|
borrarlo: se sigue viendo todo (tablero, tickets, historia, estadísticas, y quien lo administra sigue viendo sus
|
|
reglas y webhooks), pero ninguna orden lo cambia (`422`, «está archivado»): ni tickets, ni comentarios, ni votos,
|
|
ni ajustes, ni miembros; las automatizaciones no se ejecutan y el formulario público no acepta propuestas. Seguir un
|
|
ticket sí se puede, porque solo atañe a quien lo sigue. `unarchiveProject` / `POST …/unarchive` lo devuelve.
|
|
`Project.archivedAt` dice desde cuándo.
|
|
|
|
### Configuración por proyecto
|
|
|
|
Cada proyecto tiene sus **estados**, **transiciones**, **tipos de ticket**, **áreas** y **campos personalizados**
|
|
(`Project.settings`). Todos
|
|
empiezan con los valores por defecto de abajo, y el propietario o un admin los cambia con `updateProjectSettings` (o
|
|
`PUT /api/projects/{project}/settings`); el cambio es un evento más (`ProjectSettingsChangedEvent`).
|
|
|
|
| Valor | Campos | Por defecto |
|
|
| ------ | -------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- |
|
|
| Estado | `id`, `name`, `category`, `color`, `requiresReason`, `archived` (el orden es el del tablero) | `BACKLOG`, `TODO`, `IN_PROGRESS`, `IN_REVIEW`, `READY_FOR_QA`, `IN_QA`, `QA_FAILED`, `DONE`, `CANCELLED` |
|
|
| Tipo | `id`, `name`, `icon`, `color`, `archived` | `BUG`, `FEATURE`, `UI_CHANGE`, `TASK`, `QA` |
|
|
| Área | `id`, `name`, `archived` | `FRONTEND`, `BACKEND`, `MOBILE`, `QA`, `DESIGN`, `DEVOPS` |
|
|
|
|
La **categoría** de un estado dice qué significa, se llame como se llame: `TODO` (pendiente), `IN_PROGRESS`, `QA`,
|
|
`DONE` y `CANCELLED`. Releases («todo terminado» = `DONE` o `CANCELLED`), «Mi trabajo», el seguimiento y las
|
|
estadísticas trabajan con categorías, así que siguen funcionando con estados propios. Los tickets guardan el id del
|
|
estado y, en el read model, su categoría (`Issue.statusCategory`).
|
|
|
|
Reglas:
|
|
|
|
- **No se borra lo que ya existe: se archiva.** Tickets, historial y estadísticas siguen apuntando a ese id. Un valor
|
|
archivado no se ofrece para tickets nuevos ni recibe transiciones; los tickets que ya lo tienen lo conservan.
|
|
- Los ids son estables (`A-Z`, `0-9`, `_`, hasta 30, empezando por letra); los nombres, de 1 a 40 caracteres, se
|
|
pueden cambiar cuando se quiera.
|
|
- Hace falta al menos un estado activo de categoría `TODO` (los tickets nuevos entran en el primero), uno `DONE` y un
|
|
tipo activo.
|
|
- Las transiciones solo pueden apuntar a estados que existen y nunca de un estado a sí mismo.
|
|
- Colores: uno de la paleta (`slate`, `stone`, `violet`, `blue`, `sky`, `teal`, `green`, `amber`, `orange`, `red`,
|
|
`pink`), que tiene su versión para modo oscuro, o cualquier `#rrggbb`. Iconos:
|
|
`bug`, `feature`, `palette`, `task`, `qa`, `flag`, `star-fill`, `rocket`, `document`, `light-bulb`, `key`, `support`.
|
|
|
|
Un proyecto nuevo puede **empezar con la configuración de otro** que administres: `templateProjectId` en
|
|
`createProject` o `template` (id o clave) en `POST /api/projects`. Se copian una vez sus estados, transiciones, tipos,
|
|
áreas, campos personalizados y automatizaciones (las pausadas, pausadas; las que asignan a alguien que no está en el
|
|
nuevo proyecto se quedan fuera). Las etiquetas no tienen catálogo, así que no hay nada que copiar.
|
|
|
|
Los proyectos creados antes de que existiera la configuración usan los valores por defecto (sus eventos no la
|
|
llevan). Al arrancar, la API rellena la categoría de los tickets del read model que no la tienen.
|
|
|
|
### Equipos
|
|
|
|
`settings.teams`: grupos de miembros (`id`, `name`, `memberIds`, `archived`), como mucho 30. Al guardar, quien no es
|
|
miembro del proyecto se quita del equipo; un equipo no se borra, se archiva; y si `teams` no viene, se quedan como
|
|
estaban. Un ticket va a un equipo con `team` (en `createIssue` / `updateIssue` y por REST), y la lista se filtra con
|
|
`teams` (`?team=FRONT`). En un comentario, `@[Frontend](team-FRONT)` avisa a cada miembro del equipo («mencionó a tu
|
|
equipo…»).
|
|
|
|
### Presencia
|
|
|
|
Quién más está en un ticket ahora. La página de un ticket late cada 10 s con `touchPresence(issueId, editing)` (o
|
|
`PUT /api/issues/{issue}/presence` con `{ editing }`), y la respuesta es quién más está, primero los que editan. Se
|
|
guarda en Redis (un hash por ticket, `task-stream:presence:<id>`) y una entrada sin latido en 30 s desaparece sola;
|
|
`leavePresence` (`DELETE …/presence`) la quita al salir. No es historia: no pasa por el event store. Solo para quien
|
|
puede leer el ticket.
|
|
|
|
### Definición de «Hecho»
|
|
|
|
`settings.checklistBeforeDone` (desactivada por defecto): con ella, un ticket no pasa a un estado de categoría `DONE`
|
|
mientras le quede algún paso de la checklist sin marcar (`422`, «quedan N pasos…»). El propietario y los admins sí
|
|
pueden cerrarlo; las automatizaciones, no. Al guardar la configuración, si no viene, se queda como estaba.
|
|
|
|
### Campos personalizados
|
|
|
|
Datos propios del proyecto en cada ticket («Navegador», «Cliente», «Coste»…), en `settings.customFields`: `id`, `name`,
|
|
`type` y `archived`, más `options` si es una lista. Un proyecto tiene como mucho 30, y empieza sin ninguno.
|
|
|
|
| Tipo | Valor |
|
|
| -------- | ---------------------------------------------------------------- |
|
|
| `TEXT` | Hasta 500 caracteres |
|
|
| `NUMBER` | Un número (se guarda en forma canónica: `1,5` → `1.5`) |
|
|
| `DATE` | `YYYY-MM-DD` |
|
|
| `SELECT` | Una de sus `options` (de 1 a 50, de hasta 60 caracteres, únicas) |
|
|
|
|
- Como el resto de la configuración, un campo no se borra: se archiva. Su **tipo no cambia** una vez guardado, para que
|
|
los valores que ya hay sigan teniendo sentido. Un campo archivado se puede vaciar en un ticket, pero no rellenar.
|
|
- Al guardar la configuración, si `customFields` no viene se quedan como estaban (los clientes que no los conocen no
|
|
los pierden).
|
|
- En el ticket: GraphQL `Issue.customFields` es una lista `{ fieldId, value }` (solo los que tienen valor) y se cambia
|
|
con `customFields: [{ fieldId, value }]` en `createIssue` / `updateIssue`; por REST es un objeto
|
|
`{ "BROWSER": "Safari" }`. En ambos se mandan solo los campos que cambian, y `null` o `""` vacía uno.
|
|
- Filtrar: `issues(filter: { customFields: [{ fieldId, value }] })` o `GET /api/projects/{project}/issues?field=BROWSER:Safari`
|
|
(se puede repetir).
|
|
- `IssueUpdatedEvent` lleva el mapa entero de valores ya validado, así la proyección solo lo copia.
|
|
|
|
### Workflow por defecto
|
|
|
|
```mermaid
|
|
stateDiagram-v2
|
|
[*] --> BACKLOG
|
|
BACKLOG --> TODO
|
|
BACKLOG --> IN_PROGRESS
|
|
BACKLOG --> CANCELLED
|
|
TODO --> BACKLOG
|
|
TODO --> IN_PROGRESS
|
|
TODO --> CANCELLED
|
|
IN_PROGRESS --> TODO
|
|
IN_PROGRESS --> IN_REVIEW
|
|
IN_PROGRESS --> READY_FOR_QA
|
|
IN_PROGRESS --> CANCELLED
|
|
IN_REVIEW --> IN_PROGRESS
|
|
IN_REVIEW --> READY_FOR_QA
|
|
IN_REVIEW --> DONE
|
|
READY_FOR_QA --> IN_QA
|
|
READY_FOR_QA --> IN_PROGRESS
|
|
IN_QA --> DONE
|
|
IN_QA --> QA_FAILED
|
|
QA_FAILED --> IN_PROGRESS
|
|
QA_FAILED --> TODO
|
|
DONE --> TODO
|
|
DONE --> IN_PROGRESS
|
|
CANCELLED --> BACKLOG
|
|
```
|
|
|
|
- `QA_FAILED` y `CANCELLED` exigen un **motivo** (`requiresReason`), que queda en el historial.
|
|
- No se puede pasar a `DONE` saltándose QA salvo desde `IN_REVIEW` (cambios que no requieren QA).
|
|
- `Issue.allowedTransitions` devuelve los estados activos a los que puede pasar el ticket según el workflow de su
|
|
proyecto.
|
|
|
|
### Otras reglas
|
|
|
|
- **Proyecto**: clave de 2 a 10 caracteres alfanuméricos que empieza por letra y es única (los tickets se numeran
|
|
`CLAVE-1`, `CLAVE-2`…).
|
|
- **Asignación**: solo a miembros del proyecto. Para añadir miembros, el usuario debe existir y estar activo en Pireon.
|
|
- **Relaciones**: nunca consigo mismo ni duplicadas; pueden cruzar proyectos si el actor puede ver el destino. Se
|
|
muestran en los dos tickets (`OUTWARD` en el origen, `INWARD` en el destino).
|
|
- **Releases**: solo las `PLANNED` aceptan o pierden tickets; publicar con tickets sin terminar exige `force: true`.
|
|
Las notas de versión (`notes`, Markdown) se escriben también después de publicar (no una vez archivada);
|
|
`notesDraft` / `GET /api/releases/{id}/notes/draft` propone un borrador con los tickets hechos agrupados por tipo
|
|
(los de QA quedan fuera).
|
|
- **Comentarios**: solo el autor edita; borra el autor o un admin del proyecto. Se puede responder a un comentario
|
|
(`replyTo`), con un solo nivel de respuestas: responder a una respuesta la suma al mismo hilo, y la respuesta tiene
|
|
que ser del mismo ticket. Quien escribió el comentario recibe un aviso (`REPLIED`). Un comentario borrado que tiene
|
|
respuestas se queda, sin texto (`deleted: true`), para que el hilo se siga entendiendo, y desaparece con su última
|
|
respuesta; no cuenta en `commentCount`.
|
|
- **Formato**: descripciones, comentarios y notas de versión se guardan en Markdown; la API los guarda tal cual y la web
|
|
los muestra limpiando el HTML (DOMPurify). En la web se escriben con un editor visual que lee y escribe ese mismo
|
|
Markdown, así que un cliente de la API (o el MCP) sigue mandando Markdown. Una imagen pegada se sube como adjunto y queda en el texto como
|
|
``: la subida responde `attachmentId`, y la web la descarga con la sesión de quien lee, así
|
|
que sirve también en proyectos privados.
|
|
- **Reacciones**: 👍 👎 😄 🎉 😕 ❤️ 🚀 👀 en los comentarios (`reactToComment(commentId, emoji, on)`,
|
|
`POST /api/comments/{id}/reactions`); los miembros, también los lectores. Cada comentario da `reactions` con quién
|
|
eligió cada una. No generan actividad ni avisos.
|
|
- **Conversaciones**: un comentario raíz y sus respuestas se resuelven (`resolveThread(commentId, resolved, conclusion)`,
|
|
`PUT/DELETE /api/comments/{id}/resolution`; evento `CommentThreadResolvedEvent`) por quien puede cambiar el ticket;
|
|
una respuesta no se resuelve sola. Si trae `conclusion`, antes de cerrarla se añade como respuesta marcada como
|
|
decisión. El comentario da `resolved`, `resolvedBy` y `resolvedAt`. **Escribiendo**: `typing(issueId, threadId)`
|
|
(mutation, cada pocos segundos mientras se escribe) avisa al momento por la subscription `typing(issueId)` a quien
|
|
tenga el ticket abierto; no se guarda (canal de Redis `tracker.typing.<ticket>`), y cada cliente descarta sus propios
|
|
avisos.
|
|
- **Decisiones**: quien puede cambiar el ticket marca un comentario como decisión (`markDecision(commentId, on)`,
|
|
`PUT/DELETE /api/comments/{id}/decision`; evento `CommentDecisionMarkedEvent`). El comentario da `decision`,
|
|
`decidedBy` y `decidedAt`; el ticket, `decisions` (más antiguas primero, `GET /api/issues/{issue}/decisions`), que la
|
|
web muestra encima de la descripción. Se buscan por su texto: `decisions(projectId?, search, limit)`,
|
|
`GET /api/projects/{project}/decisions` y, de todos mis proyectos, `GET /api/me/decisions` (más recientes primero; sin
|
|
las de la papelera y, para un invitado, solo las de sus tickets).
|
|
- **Bloqueos**: un ticket espera a otro si ese otro lo bloquea (`BLOCKS`) o si él depende de ese otro (`DEPENDS_ON`),
|
|
mientras el otro no esté hecho ni cancelado. `blockedBy` lo calcula al leer (los enlaces no guardan el estado del
|
|
otro ticket), con una sola consulta por tablero o página.
|
|
- **Duplicar**: `duplicateIssue` / `POST /api/issues/{issue}/duplicate` copia lo que dice el ticket (tipo, título con
|
|
«(copia)», descripción, área, prioridad, etiquetas, Figma, estimación, fechas, épica y pasos de la checklist sin
|
|
marcar), no dónde está: la copia empieza en el primer estado, sin asignar, sin sprint, release, comentarios ni
|
|
adjuntos.
|
|
- **Cambios de UI**: `figmaUrl` debe ser un enlace `https://…figma.com/…`.
|
|
- **Adjuntos**: los suben los miembros (no los lectores), hasta `ATTACHMENTS_MAX_MB` (25 MB) por archivo y 50 por
|
|
ticket; pueden ir en el ticket o bajo uno de sus comentarios (`commentId`). Quita uno quien lo subió o un admin.
|
|
Los bytes se guardan primero y el evento después, así que nunca queda un adjunto apuntando a un archivo que no
|
|
existe; si el evento no se guarda, el archivo se borra. Al borrar un ticket se borran sus archivos.
|
|
- **Épicas y subtareas**: un ticket puede colgar de otro del mismo proyecto (`parent`), hasta tres niveles
|
|
(épica → ticket → subtarea) y sin bucles; se comprueba sobre el event store, no sobre el read model. Una «épica» es
|
|
cualquier ticket de primer nivel del que cuelgan otros (`epics(projectId)` da su progreso: terminados = hechos o
|
|
cancelados, y puntos). Si se borra el padre, sus hijos vuelven al primer nivel.
|
|
- **Orden del backlog**: cada ticket tiene un `rank` (texto que se compara carácter a carácter). Mover uno
|
|
(`rankIssue`, `PUT /api/issues/{issue}/rank`) solo reescribe ese ticket: recibe un rango entre el de quien queda
|
|
encima y el de quien queda debajo, leídos del event store; si entretanto el orden cambió, la API lo rechaza y la web
|
|
recarga. Un ticket que nunca se movió ocupa su orden de creación (`IssueRankedEvent` no existe para él). Las
|
|
columnas del tablero y `issues(filter: { sort: RANK })` siguen este orden.
|
|
- **Alertas → bugs**: un owner o admin activa en Integraciones una URL secreta por proyecto,
|
|
`POST /api/webhooks/alerts/{proyecto}/{token}` (el token es el secreto: Sentry o Uptime Kuma solo dejan pegar una
|
|
URL). Entiende Sentry (integración interna, cabecera `sentry-hook-resource`, o el plugin de webhooks), Uptime Kuma,
|
|
Prometheus Alertmanager y Grafana; cualquier otra herramienta manda JSON con `title` y, si quiere, `fingerprint`,
|
|
`description`, `url` y `resolved`. Cada aviso tiene una huella (el id del problema en Sentry, el monitor en Uptime
|
|
Kuma, el `groupKey` en Alertmanager o `fingerprint`): la primera vez TaskStream abre un bug (actor `system`, etiqueta
|
|
`alerta`, el tipo y la prioridad elegidos; sin tipo, el primero con icono de bug); mientras ese bug siga abierto, las
|
|
repeticiones van a él (un comentario como mucho cada 30 minutos, con cuántas van) y «resuelta» se dice en él; si ya
|
|
está cerrado, abre otro que nombra el anterior. Responde `{ action: created | repeated | resolved | ignored, issueKey
|
|
}`; proyecto desconocido, alertas apagadas o token malo responden igual: 403. Las alertas de un proyecto se atienden
|
|
de una en una, así que dos iguales a la vez abren un solo bug. Configuración: `alertIntegration`,
|
|
`saveAlertIntegration(input: { typeId, priority })`, `rotateAlertToken`, `disableAlertIntegration`;
|
|
`GET/PUT/DELETE /api/projects/{project}/alert-integration` y `POST …/token`. Vive en MongoDB (`alert_integrations`,
|
|
y `alert_traces` con qué bug abrió cada huella), no en el event store.
|
|
- **Integración con Git**: un owner o admin añade en la configuración del proyecto sus repositorios (`api`, `web`,
|
|
`admin`…), cada uno con su propio secreto. Todos llaman a la misma URL, `POST /api/webhooks/git/{proyecto}`, con los
|
|
eventos _push_ y _pull/merge request_; GitHub y Gitea firman el cuerpo con el secreto (HMAC-SHA256) y GitLab lo envía
|
|
como `X-Gitlab-Token`, y el secreto que valida la firma dice de qué repositorio viene. Cada commit, rama o PR que
|
|
nombre una clave del proyecto (`WEB-12`, también en minúsculas) queda en `devLinks` del ticket con el nombre de su
|
|
repositorio (`IssueDevLinkRecordedEvent`, actor `system`); el mismo enlace solo vuelve a contar si cambia (una PR
|
|
fusionada). Proyecto desconocido, sin repositorios o firma mala responden igual: 403. Cambiar el secreto de un
|
|
repositorio o quitarlo no afecta a los demás, y lo ya enlazado se queda. Los repositorios viven en MongoDB
|
|
(`git_integrations`, un documento por proyecto), no en el event store; un proyecto que activó el webhook único de
|
|
antes conserva su secreto como repositorio «principal».
|
|
- **Escaparate público**: `GET /api/explore/projects/{project}/showcase` resume un proyecto público para webs de
|
|
fuera (la del colectivo lo enseña en la página de cada proyecto): épicas con fechas y progreso, las últimas
|
|
versiones (con sus notas si ya salieron), el sprint en curso y los tickets abiertos con la etiqueta
|
|
`good-first-issue`. Sin token, con `Cache-Control: public, max-age=300`; un proyecto privado responde 404.
|
|
- **Papelera**: `trashIssue` / `DELETE /api/issues/{issue}` manda un ticket a la papelera (quien lo abrió o un owner/
|
|
admin; `IssueTrashedEvent`): desaparece del tablero, las búsquedas, las estadísticas y el progreso, y no se puede
|
|
cambiar hasta recuperarlo (`restoreIssue` / `POST …/restore`, `IssueRestoredEvent`). `trash(projectId)` /
|
|
`GET /api/projects/{project}/trash` los lista. A los `TRASH_DAYS` días (30) se borra del todo, con la cascada de
|
|
siempre: comentarios, enlaces y subtareas de vuelta al primer nivel.
|
|
- **Edición en bloque**: `bulkUpdateIssues` / `POST /api/issues/bulk` aplica el mismo cambio (estado, persona o
|
|
`unassign`, sprint o `noSprint`, release o `noRelease`, prioridad, etiquetas que añadir) a hasta 100 tickets. Cada
|
|
uno pasa por los comandos normales (permisos, flujo, sprints y releases abiertos) y puede negarse sin parar al resto;
|
|
la respuesta dice cuáles. La web parte selecciones mayores (la Lista enseña hasta 200) en tandas de 100, una tras
|
|
otra, y suma el resultado.
|
|
- **Archivar una release**: `archiveRelease(releaseId, moveTo)` / `POST /api/releases/{id}/archive` (`{ moveTo }`).
|
|
Sus tickets sin terminar pasan a `moveTo` (otra release abierta del mismo proyecto) o, sin él, se quedan sin release;
|
|
los terminados se quedan, como registro de lo que salió. Igual que al cerrar un sprint con `carryOverTo`.
|
|
- **Plantillas por tipo**: en la configuración, cada tipo de ticket puede llevar `template` con una descripción
|
|
(hasta 5000 caracteres) y una checklist (hasta 20 pasos). Al crear un ticket de ese tipo, la descripción se usa si
|
|
llega vacía y los pasos se añaden siempre (las importaciones no aplican plantillas).
|
|
- **Automatizaciones**: owners y admins crean reglas (hasta 50 por proyecto, por GraphQL: `automations`,
|
|
`createAutomation`, `updateAutomation`, `deleteAutomation`). Disparador: ticket creado, cambio de estado (opcionalmente
|
|
a uno concreto), asignación, comentario o PR fusionada (integración Git). Condiciones: tipo, prioridad, área o
|
|
etiqueta. Acción: mover a un estado, asignar (a alguien o a quien lo abrió), añadir una etiqueta, poner una
|
|
prioridad, comentar o meter en el sprint en curso. Actúan como `system` («TaskStream») y respetando el flujo: si la
|
|
transición no está permitida, la regla no hace nada y lo deja en `lastError`. Lo que hace una regla no dispara
|
|
otras (no hay cadenas ni bucles) y cada regla corre una sola vez por evento aunque se reentregue
|
|
(`automation_runs`). Viven en MongoDB (`automations`), fuera del event store.
|
|
- **Webhooks salientes**: owners y admins dan de alta URLs (hasta 20 por proyecto) suscritas a eventos
|
|
(`issue.created`, `issue.status_changed`, `comment.added`, `release.shipped`, `sprint.closed`… — la lista en
|
|
`webhookEvents` / `GET /api/webhook-events`), a familias (`issue.*`) o a todo (`*`). Cada envío es un `POST` JSON con
|
|
`event`, `deliveryId`, `occurredAt`, `actorId`, `project`, `issue` (si va de un ticket) y `data` (los campos del evento),
|
|
y las cabeceras `X-TaskStream-Event`, `X-TaskStream-Delivery` y `X-TaskStream-Signature: sha256=<HMAC del cuerpo con el
|
|
secreto>`. El secreto solo se muestra al crearlo. Se reintenta a los 2 y 10 s (no ante un 4xx), con 5 s de espera
|
|
máxima; se guarda el último envío. Salen en segundo plano y no son duraderos: si la API se reinicia a mitad, ese
|
|
envío se pierde. No llaman a direcciones privadas salvo con `WEBHOOKS_ALLOW_PRIVATE=true`.
|
|
- **Importar y exportar**: `GET /api/projects/{project}/export.csv` da todos los tickets (UTF-8 con BOM; las celdas
|
|
que una hoja de cálculo ejecutaría como fórmula empiezan por `'`). `POST /api/projects/{project}/import` (owners y
|
|
admins, multipart `file`, hasta 5 MB y 2000 filas) lee ese mismo CSV o una exportación CSV de Jira: tipos, estados y
|
|
personas se buscan por id o por nombre (los de Jira por su significado: _Story_ → Feature, _Highest_ → crítica,
|
|
categoría _In Progress_ → primer estado en curso…), lo que no encaja se queda con un valor por defecto y un aviso, y
|
|
los padres se reconstruyen al final. Con `dryRun=true` solo se cuenta lo que se crearía. Cada ticket importado es una
|
|
creación normal (con sus eventos), en el estado que traía si está activo.
|
|
- **Límite WIP**: cada estado puede tener `wipLimit` (1-999) en la configuración del proyecto. Es un aviso, no un
|
|
bloqueo: la API acepta pasarse y el tablero marca la columna.
|
|
- **Sprints**: los planifica y gestiona quien administra el proyecto (owner o admin); mete tickets cualquier miembro.
|
|
Un sprint va de `PLANNED` a `ACTIVE` (uno solo en curso por proyecto) y a `CLOSED`; dura como mucho 90 días y un
|
|
sprint en curso conserva su fecha de inicio. Un sprint cerrado ya no acepta ni pierde tickets. Al cerrarlo se elige
|
|
adónde van los tickets sin terminar: a otro sprint abierto del proyecto o al backlog; los terminados se quedan como
|
|
registro. Solo se borran sprints que no han empezado (sus tickets vuelven al backlog).
|
|
- **Vistas guardadas**: filtros del tablero con nombre. Las guardan los miembros del proyecto (los lectores, solo para
|
|
sí); compartir una la hace visible a todo el proyecto. Borra una su autor, o un admin si es compartida; como mucho
|
|
50 por persona y proyecto. Son preferencias, no historia: viven en MongoDB (`saved_filters`), fuera del event store,
|
|
y se borran con el proyecto.
|
|
- **Checklist**: hasta 100 pasos de hasta 300 caracteres por ticket; los cambia quien puede editar el ticket.
|
|
|
|
### Calendario (iCalendar)
|
|
|
|
Cada persona puede suscribir sus fechas en Google Calendar, Outlook o Apple Calendar con una URL secreta:
|
|
`GET /api/calendar/{token}.ics` (sin sesión: las aplicaciones de calendario no pueden iniciarla). Trae, como eventos de
|
|
día completo:
|
|
|
|
- sus tickets sin terminar con fecha límite, asignados o que sigue;
|
|
- los sprints planificados o en curso de sus proyectos;
|
|
- las releases planificadas con fecha objetivo.
|
|
|
|
Cada evento enlaza a la web (`WEB_URL`). El token (32 bytes aleatorios) se crea, renueva o retira con `GET`, `POST` y
|
|
`DELETE /api/me/calendar` (GraphQL: `myCalendarFeed`, `createCalendarFeed`, `revokeCalendarFeed`); renovarlo deja sin
|
|
servicio la URL anterior. Se guarda tal cual en `calendar_feeds` para poder volver a copiar la URL: solo da lectura de
|
|
fechas y títulos que ya están en el read model.
|
|
|
|
### Panel para clientes
|
|
|
|
Cada proyecto puede enseñar su estado a sus clientes con un enlace de solo lectura, sin cuenta: `/share/{token}` en la
|
|
web, que lee `GET /api/shared/dashboards/{token}` (GraphQL: `sharedDashboard(token)`), ambos sin sesión. Solo trae los
|
|
bloques elegidos, y los demás llegan a `null`:
|
|
|
|
| Bloque | Qué da |
|
|
| ---------- | -------------------------------------------------------------------------- |
|
|
| `flow` | Tickets en cada estado activo, con su nombre y color |
|
|
| `sprint` | El sprint en curso, como en el panel (¿llega?) |
|
|
| `releases` | Las releases planificadas con su previsión al ritmo real |
|
|
| `epics` | Las épicas abiertas (8, por fecha): título, fecha, terminados y porcentaje |
|
|
| `activity` | Movimientos por día de las últimas 12 semanas |
|
|
|
|
No enseña personas ni tickets sueltos (salvo las épicas). Propietarios y admins lo activan, cambian sus bloques o lo
|
|
renuevan con `GET`, `PUT` y `DELETE /api/projects/{project}/share` (`{ blocks, renew }`; GraphQL: `dashboardShare`,
|
|
`shareDashboard`, `unshareDashboard`). Renovarlo deja sin servicio el enlace anterior. El token (32 bytes aleatorios)
|
|
se guarda tal cual en `dashboard_shares` para poder volver a copiar el enlace, y se borra con el proyecto.
|
|
|
|
### Notificaciones
|
|
|
|
Se derivan de los eventos (`NotificationsEventHandler`) y se guardan **por persona** en MongoDB: no son estado de
|
|
ningún agregado, solo cada uno lee y descarta las suyas.
|
|
|
|
| Cuándo | A quién | Tipo |
|
|
| ---------------------------------------- | ---------------------------------------- | ------------------ |
|
|
| Se crea un ticket asignado o se asigna | La persona asignada | `ASSIGNED` |
|
|
| Un ticket cambia de estado | Quien lo tiene asignado y quien lo abrió | `STATUS_CHANGED` |
|
|
| Un ticket vuelve de QA sin pasar a hecho | Los mismos, con el motivo | `QA_FAILED` |
|
|
| Alguien comenta | Quien lo tiene asignado y quien lo abrió | `COMMENTED` |
|
|
| Alguien responde a un comentario | Quien escribió el comentario | `REPLIED` |
|
|
| Te añaden a un proyecto | La persona añadida | `ADDED_TO_PROJECT` |
|
|
| Llega una propuesta del público | Propietarios y admins del proyecto | `PROPOSED` |
|
|
|
|
**Push** (Web Push): además de en la campana y, si se pidió, por correo, cada aviso llega a los dispositivos donde la
|
|
persona activó «Avisos en este dispositivo», con TaskStream cerrado. El navegador se suscribe con la clave pública
|
|
VAPID (`notificationSettings.pushPublicKey`, null si no hay claves) y la API guarda la suscripción
|
|
(`subscribePush` / `POST /api/me/push-subscriptions`; se retira con `unsubscribePush` / `DELETE`). El mensaje va
|
|
cifrado (`aes128gcm`) y firmado con VAPID, con `{ title, body, url, tag }`; si el servicio push responde 404 o 410, la
|
|
suscripción se olvida. Como el correo, un fallo de envío no frena los eventos.
|
|
|
|
**Recordatorios** (`REMINDER`): cada hora la API mira los tickets sin terminar con fecha límite y avisa a quien lo
|
|
tiene asignado y a quien lo sigue el día antes, el mismo día y una vez cuando ya ha vencido (el día cambia en
|
|
`REMINDERS_TIMEZONE`). Cada aviso tiene id `reminder:<ticket>:<fecha>:<fase>:<persona>`, así que no se repite aunque
|
|
varias instancias lo comprueben a la vez.
|
|
|
|
Nadie recibe aviso de lo que hizo él mismo, y cada persona recibe uno por evento: la primera razón de la tabla gana
|
|
(una mención antes que «comentó en tu ticket», y eso antes que «un ticket que sigues»).
|
|
|
|
**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 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 })`,
|
|
`?watching=true`; sin sesión, ninguno). **Seguir todo**: seguir un ticket con todo lo que cuelga de él (una épica, un ticket con subtareas):
|
|
`followTree`/`unfollowTree`, `Issue.followingTree`; `PUT/DELETE /api/issues/{issue}/follow-tree`. Quien lo hace oye lo
|
|
mismo que un seguidor de cada ticket de debajo, a cualquier profundidad, también de los que se añadan, y además cuándo
|
|
uno entra (`IssueParentChangedEvent`). Personal y sin historial (colección `tree_follows`); los invitados de un proyecto
|
|
privado no oyen los tickets que no son suyos. **Fijar**: cada persona fija hasta 20 tickets para tenerlos a mano en el
|
|
inicio y en la búsqueda (`pinIssue`/`unpinIssue`, `myPins`, `Issue.pinned`; `GET /api/me/pins`,
|
|
`PUT/DELETE /api/issues/{issue}/pin`). Son privados y sin historial (colección `pinned_issues`, como las vistas de «Mi
|
|
trabajo»); los que están en la papelera o fuera de su alcance no se listan, y al llegar a 20 se olvidan primero los
|
|
que ya no puede leer. El id es
|
|
`<agregado>:<versión>:<persona>`, así que reentregar un evento no duplica avisos. Llegan en vivo por la subscription
|
|
`notificationAdded` y, si la instalación tiene `SMTP_URL`, también por correo a quien lo active (`email`, desactivado
|
|
por defecto); un correo que falla se registra y no bloquea el stream.
|
|
|
|
### Eventos de dominio
|
|
|
|
Todo cambio es un evento guardado en el event store (stream = id del agregado). Llevan `actorId`, `occurredAt`,
|
|
`aggregateId`, `projectId` y `aggregateVersion` (la posición en el stream).
|
|
|
|
| Agregado | Eventos |
|
|
| -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
| Project | `ProjectCreatedEvent`, `ProjectUpdatedEvent`, `ProjectSettingsChangedEvent`, `ProjectMemberAddedEvent`, `ProjectMemberRoleChangedEvent`, `ProjectMemberRemovedEvent`, `ProjectDeletedEvent` |
|
|
| Issue | `IssueCreatedEvent`, `IssueUpdatedEvent`, `IssueStatusChangedEvent`, `IssueAssignedEvent`, `IssueLinkedEvent`, `IssueUnlinkedEvent`, `IssueReleaseChangedEvent`, `IssueAttachmentAddedEvent`, `IssueAttachmentRemovedEvent`, `IssueWatchedEvent`, `IssueUnwatchedEvent`, `IssueParentChangedEvent`, `IssueRankedEvent`, `IssueSprintChangedEvent`, `IssueDevLinkRecordedEvent`, `IssueChecklistItemAddedEvent`, `IssueChecklistItemUpdatedEvent`, `IssueChecklistItemRemovedEvent`, `IssueDeletedEvent` |
|
|
| Comment | `CommentAddedEvent`, `CommentEditedEvent`, `CommentDeletedEvent` |
|
|
| Sprint | `SprintCreatedEvent`, `SprintUpdatedEvent`, `SprintStartedEvent`, `SprintClosedEvent`, `SprintDeletedEvent` |
|
|
| Release | `ReleaseCreatedEvent`, `ReleaseUpdatedEvent`, `ReleaseShippedEvent`, `ReleaseNotesWrittenEvent`, `ReleaseArchivedEvent`, `ReleaseDeletedEvent` |
|
|
|
|
`IssueStatusChangedEvent` lleva además `meta` con el nombre y la categoría de los dos estados en el momento del
|
|
cambio, para que el historial y las estadísticas no cambien si luego se renombra o recategoriza un estado.
|
|
|
|
### Estadísticas
|
|
|
|
Una proyección más (`issue_timeline` en MongoDB) guarda cada paso de cada ticket: a qué estado pasó, con qué categoría,
|
|
quién lo tenía asignado, y cuándo entró o salió de una release. Las estadísticas se calculan sobre ella con funciones
|
|
puras (`application/src/lib/analytics`):
|
|
|
|
| Métrica | Definición |
|
|
| --------------------- | ----------------------------------------------------------------------------------------------------------------- |
|
|
| Creados vs. resueltos | Por semana (lunes, UTC): tickets creados y tickets que entraron en una categoría `DONE` |
|
|
| Flujo acumulado | Tickets en cada estado al final de cada día |
|
|
| Tiempo de ciclo | Del primer paso a `IN_PROGRESS` o `QA` hasta `DONE`; media y percentil 85 |
|
|
| Tiempo de entrega | De la creación hasta `DONE`; media y percentil 85 |
|
|
| Throughput | Tickets resueltos por semana y por la persona que los tenía asignados |
|
|
| QA | Salidas de un estado `QA`: a `DONE` cuenta como superado; a cualquier otro sitio salvo `CANCELLED`, como fallido |
|
|
| Burndown de un sprint | Por día del sprint: tickets en él (sin cancelados), cuántos siguen abiertos y sus puntos; línea ideal hasta cero |
|
|
| Burnup de una release | Por día, desde que se creó hasta que se publica (o hoy, máx. 180 días): tickets en ella (sin cancelados) y hechos |
|
|
|
|
Los tickets anteriores a esta proyección solo tienen su creación y su estado actual; para tener su historia completa,
|
|
reconstruye el read model (ver [Despliegue](07-despliegue.md)).
|
|
|
|
La **cartera** (`portfolio` en GraphQL, `GET /api/me/portfolio`) junta, para cada proyecto activo de quien pregunta
|
|
(no archivado y en el que no es invitado), los tickets abiertos, vencidos, bloqueados y atascados, el sprint en curso,
|
|
la previsión de sus releases y sus épicas abiertas (las 6 con fecha límite más cercana). Se calcula con lo mismo que el
|
|
panel.
|
|
|
|
La **portada** (`myHome` en GraphQL, `GET /api/me/home`) junta lo demás que se ve en la página de inicio
|
|
(`analytics/home.ts`):
|
|
|
|
| Bloque | Qué da |
|
|
| ------------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
| `agenda` | Los próximos 7 días: mis tickets abiertos (asignados o que sigo) que vencen, fin de sprints en curso y releases planificadas |
|
|
| `activity` | Lo último en mis proyectos (30 entradas; sin los proyectos en los que soy invitado) |
|
|
| `team` / `me` | Tickets terminados por día en los últimos 14 días; esta semana y la anterior, de mis equipos y míos (por quien lo tenía asignado), y mi racha |
|
|
| `sprints` | Los sprints en curso, con el mismo pulso que el panel (¿llega?) |
|
|
| `projects` | Por proyecto: abiertos, vencidos, actividad por día (14 días) y el sprint en curso con su porcentaje |
|
|
| `suggestions` | Para cuando no tengo nada: sin asignar, esperando QA y propuestas por revisar (estas, solo a quien administra); las 5 mayores |
|
|
| `firstSteps` | Si ya creé un proyecto, invité a alguien, abrí un ticket y conecté el calendario |
|
|
| `online` | Quién está ahora en un ticket de mis proyectos (por la presencia, una entrada por persona) |
|
|
|
|
El **panel del proyecto** (`Project.insights` en GraphQL, `GET /api/projects/{project}/insights`) lee lo mismo para
|
|
decir qué mirar ahora (`analytics/insights.ts`; acepta `since`, un instante ISO):
|
|
|
|
| Bloque | Qué da |
|
|
| ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
| Sprint en curso | Días que quedan, tickets y puntos hechos, ritmo (puntos al día) y el que haría falta; `behind` si no llega |
|
|
| Riesgo | Abiertos vencidos, que vencen en 7 días, bloqueados (por un ticket abierto) y atascados (7 días o más sin moverse) |
|
|
| Antigüedad | Lo que está en curso o en QA, por días en su estado, con el percentil 85 del tiempo de ciclo (90 días) como umbral |
|
|
| Cuellos de botella | Días de media en cada estado en curso o de QA (estancias que terminaron en 90 días), cuántos hay y su límite WIP |
|
|
| Próximas releases | Para cada release planificada (por fecha objetivo): fecha probable (p50) y segura (p85) simulando 500 futuros con lo que se cerró cada día de las últimas 6 semanas (Monte Carlo); cada release cuenta lo que queda de las anteriores. `ON_TRACK`, `AT_RISK`, `LATE`, `NO_DATE`, `UNKNOWN` (sin ritmo aún) o `DONE` |
|
|
| Cambios | Con `since` (por ejemplo, la última visita): tickets creados, terminados y vueltos de QA desde entonces, y mis menciones en el proyecto |
|
|
| Equipo | Por persona: tickets y puntos abiertos, bloqueados, puntos cerrados por sprint (últimos 3 cerrados) y `overloaded` si tiene abiertos más de los que suele cerrar |
|
|
| Salud del backlog | Abiertos sin estimar, sin área, sin asignar y sin tocar en 90 días |
|
|
| Calidad | Bugs (tipos con icono `bug`): abiertos por prioridad, nuevos y cerrados por semana (8 semanas), lo que QA devolvió y dejó pasar por área (90 días) y bugs abiertos en los 14 días tras cada una de las 3 últimas releases |
|
|
| Campos | Para cada campo personalizado de lista: tickets abiertos por opción y sin valor |
|
|
| Actividad | Movimientos por día (todo lo de la actividad) de las últimas 12 semanas, desde un lunes: 84 días, en `heatmap` |
|
|
| Recorrido | De los tickets terminados en 90 días: días de media de creado a hecho, días y parte del total en cada estado por el que pasaron (también la espera en Backlog o Por hacer) y `flowEfficiency`, la parte en un estado «en curso» |
|
|
| Propuestas | Si el público ha propuesto algo (tickets con la etiqueta «propuesta»): recibidas (y en 30 días), esperando (sin empezar ni planificar), en marcha, hechas y descartadas, y las 5 más votadas que esperan; si no, `null` |
|
|
|
|
## 3.2 GraphQL
|
|
|
|
Las operaciones requieren `Authorization: Bearer <token de Pireon>` (en subscriptions, en el `connection_init`), salvo
|
|
las lecturas de proyectos públicos (`project`, `issue`, `issues`, `release`, `activity`, `publicProjects` y sus campos),
|
|
que funcionan también sin token.
|
|
|
|
### Queries
|
|
|
|
| Query | Devuelve |
|
|
| --------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
| `me` | El usuario autenticado, con sus roles en Pireon |
|
|
| `users(search, limit)` | Usuarios de Pireon (para asignar o añadir miembros) |
|
|
| `user(id)` | Un usuario |
|
|
| `myProjects` | Proyectos de los que soy miembro |
|
|
| `myIssues(assignedToMe, statuses, categories, search, limit, offset)` | «Mi trabajo»: tickets de todos mis proyectos, asignados a mí (por defecto) o de cualquiera (p. ej. `categories: [QA]`) |
|
|
| `upcomingReleases(limit)` | Releases planificadas de mis proyectos, por fecha objetivo |
|
|
| `publicProjects` | Proyectos públicos (sin token) |
|
|
| `project(idOrKey)` | Un proyecto (mío, o público) con su `settings`. Campos calculados: `board(releaseId, assigneeId, unassigned, area, types, priorities, labels, search, perColumn)`, `stats`, `analytics(days)`, `releases` |
|
|
| `issues(filter, limit, offset)` | Búsqueda de tickets (estado, tipo, área, prioridad, persona, release, etiquetas, ticket padre, fecha límite entre `dueFrom` y `dueTo`, texto); `sort: RANK` para el orden del backlog |
|
|
| `webhooks(projectId)`, `webhookEvents` | Webhooks salientes del proyecto (owners y admins) y los eventos a los que se pueden suscribir |
|
|
| `gitIntegration(projectId)` | Si el webhook de Git está activo, su ruta y (para owners y admins) el secreto |
|
|
| `sprints(projectId)`, `sprint(id)` | Sprints del proyecto (en curso, planificados, cerrados). Campos calculados: `progress`, `burndown`, `issues` |
|
|
| `savedFilters(projectId)` | Mis vistas guardadas del tablero y las compartidas con el proyecto |
|
|
| `epics(projectId)` | Épicas del proyecto (tickets de primer nivel de los que cuelgan otros) con el progreso de sus hijos |
|
|
| `issue(idOrKey)` | Un ticket. Campos calculados: `allowedTransitions`, `linkedIssues`, `comments`, `history` |
|
|
| `release(id)` | Una release. Campos calculados: `progress`, `burnup`, `issues` |
|
|
| `activity(projectId, issueId?, limit, before?, after?)` | Feed de actividad, del más reciente al más antiguo; con `after` y `before`, una ventana (un día, por ejemplo). Por REST, los mismos parámetros en `…/activity` |
|
|
|
|
### Mutations
|
|
|
|
Todas devuelven `CommandResult { id, key, version }`.
|
|
|
|
| Ámbito | Mutations |
|
|
| ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
| Proyectos | `createProject`, `updateProject`, `updateProjectSettings`, `deleteProject(confirmKey)`, `addProjectMember`, `changeProjectMemberRole`, `removeProjectMember` |
|
|
| Tickets | `createIssue`, `updateIssue`, `changeIssueStatus`, `assignIssue`, `linkIssues`, `unlinkIssues`, `setIssueRelease`, `setIssueParent`, `rankIssue`, `addChecklistItem`, `updateChecklistItem`, `removeChecklistItem`, `watchIssue`, `unwatchIssue`, `pinIssue`, `unpinIssue`, `followTree`, `unfollowTree` (devuelven `true`), `removeAttachment` (subir: por REST), `duplicateIssue`, `voteIssue` |
|
|
| Comentarios | `addComment(replyTo)`, `editComment`, `deleteComment`, `reactToComment`, `markDecision(commentId, on)`, `resolveThread(commentId, resolved, conclusion)`, `typing(issueId, threadId)`; consulta `decisions(projectId, search)` |
|
|
| Webhooks | `createWebhook` (devuelve el secreto), `updateWebhook`, `deleteWebhook`, `testWebhook` |
|
|
| Git | `addGitRepository` (devuelve su secreto), `rotateGitRepositorySecret`, `removeGitRepository` |
|
|
| Sprints | `createSprint`, `updateSprint`, `startSprint`, `closeSprint(carryOverToSprintId)`, `deleteSprint`, `setIssueSprint` |
|
|
| Vistas | `saveFilter(input)` (devuelve el id), `deleteSavedFilter(id)`, `GET/POST /api/me/views`, `DELETE /api/me/views/{id}` (mis vistas de «Mi trabajo») |
|
|
| Avisos | `markNotificationsRead(ids)`, `updateNotificationSettings(email)` |
|
|
| Releases | `createRelease`, `updateRelease`, `publishRelease(force)`, `writeReleaseNotes`, `archiveRelease` |
|
|
|
|
### Subscriptions
|
|
|
|
| Subscription | Se emite cuando |
|
|
| --------------------------- | -------------------------------------------------------------------------------------------------------------------------------- |
|
|
| `issueChanged(projectId)` | Se crea o cambia cualquier ticket del proyecto (incluye el ticket completo y `action` = nombre del evento) |
|
|
| `commentChanged(issueId)` | Se añade, edita o borra un comentario (`comment` es `null` al borrar, o el que queda sin texto si tenía respuestas) |
|
|
| `typing(issueId)` | Alguien escribe en el ticket (`threadId`: la conversación que responde; `null`, un comentario nuevo). Llegan también los propios |
|
|
| `activityAdded(projectId)` | Hay una nueva entrada en el feed |
|
|
| `releaseChanged(projectId)` | Cambia una release |
|
|
| `sprintChanged(projectId)` | Se planifica, cambia, empieza, cierra o borra un sprint (`sprint` es `null` al borrar) |
|
|
| `projectChanged(projectId)` | Cambian los datos, la configuración o los miembros del proyecto |
|
|
| `myProjectsChanged` | Me añaden a un proyecto o me quitan de uno |
|
|
|
|
Solo los miembros pueden suscribirse a un proyecto; las conexiones sin token válido se cierran con el código 4403.
|
|
|
|
### Ejemplos
|
|
|
|
Estados, tipos y áreas son texto (ids de la configuración del proyecto), no enums: van entre comillas.
|
|
|
|
```graphql
|
|
mutation ($settings: ProjectSettingsInput!) {
|
|
# La configuración entera (la que devuelve project.settings, cambiada).
|
|
updateProjectSettings(projectId: "…", settings: $settings) {
|
|
version
|
|
}
|
|
}
|
|
|
|
mutation {
|
|
createProject(input: { key: "SHOP", name: "Checkout revamp" }) {
|
|
id
|
|
key
|
|
version
|
|
}
|
|
}
|
|
|
|
mutation {
|
|
createIssue(input: { projectId: "…", type: "BUG", title: "El botón de pagar no responde en Safari", area: "FRONTEND", priority: CRITICAL, assigneeId: "102", labels: ["safari"] }) {
|
|
id
|
|
key
|
|
version
|
|
}
|
|
}
|
|
|
|
mutation {
|
|
changeIssueStatus(input: { issueId: "…", status: "QA_FAILED", reason: "Sigue fallando en Safari 17" }) {
|
|
version
|
|
}
|
|
}
|
|
|
|
mutation {
|
|
linkIssues(input: { issueId: "<FE>", type: DEPENDS_ON, targetIssueId: "<BE>" }) {
|
|
version
|
|
}
|
|
}
|
|
|
|
query {
|
|
project(idOrKey: "SHOP") {
|
|
stats {
|
|
total
|
|
open
|
|
byStatus {
|
|
key
|
|
count
|
|
}
|
|
byAssignee {
|
|
user {
|
|
name
|
|
}
|
|
count
|
|
}
|
|
}
|
|
board {
|
|
status
|
|
total
|
|
issues {
|
|
key
|
|
title
|
|
assignee {
|
|
name
|
|
}
|
|
}
|
|
}
|
|
releases {
|
|
name
|
|
status
|
|
progress {
|
|
percent
|
|
}
|
|
}
|
|
analytics(days: 90) {
|
|
createdVsResolved {
|
|
week
|
|
created
|
|
resolved
|
|
}
|
|
cycleTime {
|
|
averageDays
|
|
p85Days
|
|
}
|
|
qa {
|
|
failureRate
|
|
}
|
|
}
|
|
}
|
|
release(id: "…") {
|
|
burnup {
|
|
dates
|
|
scope
|
|
done
|
|
}
|
|
}
|
|
issue(idOrKey: "SHOP-1") {
|
|
status
|
|
allowedTransitions
|
|
links {
|
|
type
|
|
direction
|
|
issueKey
|
|
}
|
|
comments {
|
|
items {
|
|
author {
|
|
name
|
|
}
|
|
body
|
|
}
|
|
}
|
|
history {
|
|
summary
|
|
occurredAt
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
```ts
|
|
import { createClient } from 'graphql-ws';
|
|
|
|
const client = createClient({
|
|
url: 'ws://localhost:3000/graphql',
|
|
connectionParams: { authorization: `Bearer ${token}` },
|
|
});
|
|
|
|
client.subscribe(
|
|
{
|
|
query: 'subscription($p: ID!) { issueChanged(projectId: $p) { action version issue { key status } } }',
|
|
variables: { p: projectId },
|
|
},
|
|
{ next: console.log, error: console.error, complete: () => {} },
|
|
);
|
|
```
|
|
|
|
### Errores
|
|
|
|
Los errores de dominio llegan como errores GraphQL con `extensions.code`:
|
|
|
|
| Código | Significado |
|
|
| ----------------- | -------------------------------------------------------------- |
|
|
| `UNAUTHENTICATED` | Falta el token o Pireon lo rechaza |
|
|
| `NOT_FOUND` | No existe o no eres miembro |
|
|
| `FORBIDDEN` | No tienes permiso (p. ej. un `VIEWER` modificando) |
|
|
| `VALIDATION` | Datos inválidos (clave de proyecto, URL de Figma…) |
|
|
| `BUSINESS_RULE` | Regla de negocio (transición no permitida, release bloqueada…) |
|
|
| `CONFLICT` | Otro cambio llegó antes (concurrencia optimista): reintenta |
|
|
|
|
## 3.3 REST con OpenAPI (para otras apps)
|
|
|
|
Los mismos casos de uso que GraphQL, como API REST documentada con OpenAPI 3.0, para que otras apps de la plataforma
|
|
la consuman o generen un cliente. Son otro adaptador de entrada (`presenters/http`) que llama a los mismos
|
|
`*.service.ts`: misma validación, permisos y errores.
|
|
|
|
| | |
|
|
| ----------------- | ----------------------------------------------------------------------------------- |
|
|
| Swagger UI | `/api/docs` (con «Authorize» contra Pireon) |
|
|
| Documento OpenAPI | `/api/docs-json` (y `/api/docs-yaml`) |
|
|
| Fichero exportado | `apps/api/openapi.json`: lo reescribe `nx serve api` al arrancar, como `schema.gql` |
|
|
|
|
### Autenticación: siempre a través de Pireon
|
|
|
|
Cualquier app o usuario que la consuma pasa por el OAuth de Pireon: todas las rutas, salvo `health`, `auth`, el
|
|
webhook, `explore` y las lecturas de proyectos públicos, exigen `Authorization: Bearer <token de Pireon>` (el
|
|
`AuthGuard` global, igual que en GraphQL). En el documento OpenAPI, las rutas donde el token es opcional lo indican con
|
|
un requisito de seguridad vacío (`{}`). Declara dos esquemas:
|
|
|
|
- **`pireon-oauth`** (OAuth2): _Authorization Code + PKCE_ contra `oauth.pireon.dev/oauth/authorize` y, si
|
|
`PIREON_PASSWORD_GRANT=true`, _password_. El intercambio de tokens va por `POST /api/auth/token` (Pireon no tiene
|
|
CORS en `/oauth/token` y el `client_secret` se queda en el servidor). Para usar «Authorize» en Swagger UI, registra
|
|
`<api>/api/docs/oauth2-redirect.html` como redirect URI del cliente en Pireon.
|
|
- **`pireon`** (Bearer): para una app que ya tiene un token de Pireon, por ejemplo con su propio cliente OAuth.
|
|
|
|
Lo que cada usuario puede ver o cambiar depende de su rol en cada proyecto, como en la web.
|
|
|
|
### Recursos
|
|
|
|
Proyectos y tickets se pueden indicar por **id o por clave** (`WEB`, `WEB-12`).
|
|
|
|
| Recurso | Rutas |
|
|
| ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
| Usuarios | `GET /api/users/me`, `GET /api/users?search=`, `GET /api/users/{id}` |
|
|
| Proyectos | `GET/POST /api/projects`, `GET/PATCH/DELETE /api/projects/{project}` (`DELETE …?confirm=CLAVE`), `POST …/archive`, `POST …/unarchive`, `PUT …/settings`, `…/board`, `…/epics`, `…/stats`, `…/analytics?days=`, `…/insights`, `…/activity`, `…/members` (POST, PATCH, DELETE) |
|
|
| Tickets | `GET /api/me/home` (portada), `GET /api/me/portfolio` (cartera), `GET /api/me/issues` (mi trabajo; filtros `project`, `priority`, `bugsOnly`, `overdue`), `GET/POST /api/projects/{project}/issues`, `GET/PATCH /api/issues/{issue}`, `PUT …/status`, `PUT …/assignee`, `PUT …/release`, `PUT/DELETE …/watch`, `PUT/DELETE …/follow-tree`, `GET /api/me/pins` y `PUT/DELETE …/pin` (fijados), `PUT/DELETE …/presence`, `PUT …/parent`, `PUT …/rank`, `POST …/checklist`, `PATCH/DELETE …/checklist/{item}`, `POST/DELETE …/links`, `GET/POST …/comments`, `GET …/history`, `POST /api/issues/{issue}/duplicate`, `PUT/DELETE …/vote` |
|
|
| Comentarios | `PATCH/DELETE /api/comments/{commentId}`, `POST …/reactions`, `PUT/DELETE …/decision`, `PUT/DELETE …/resolution`; `GET /api/issues/{issue}/decisions`, `GET /api/projects/{project}/decisions`, `GET /api/me/decisions` |
|
|
| Sprints | `GET/POST /api/projects/{project}/sprints`, `GET/PATCH/DELETE /api/sprints/{sprint}`, `POST …/start`, `POST …/close`, `GET …/progress`, `…/burndown`, `…/issues`, `PUT /api/issues/{issue}/sprint` |
|
|
| Vistas | `GET/POST /api/projects/{project}/filters`, `DELETE /api/filters/{id}` |
|
|
| Avisos | `GET /api/me/notifications`, `POST /api/me/notifications/read`, `GET/PUT /api/me/notification-settings` |
|
|
| Adjuntos | `POST /api/issues/{issue}/attachments` (multipart: `file` y opcional `commentId`), `GET/DELETE /api/issues/{issue}/attachments/{id}` |
|
|
| Releases | `GET /api/me/releases` (próximas), `GET/POST /api/projects/{project}/releases`, `GET/PATCH /api/releases/{id}`, `PUT …/notes`, `GET …/notes/draft`, `…/progress`, `…/burnup`, `…/issues`, `POST …/publish`, `POST …/archive` |
|
|
| Explorar | `GET /api/explore/projects` (proyectos públicos, sin token) |
|
|
| Compartir | `GET/PUT/DELETE /api/projects/{project}/share` (enlace para clientes), `GET /api/shared/dashboards/{token}` (lo que enseña, sin token) |
|
|
| Importar | `GET /api/projects/{project}/export.csv`, `POST /api/projects/{project}/import?dryRun=` (multipart `file`) |
|
|
| Webhooks | `GET /api/webhook-events`, `GET/POST /api/projects/{project}/webhooks`, `PATCH/DELETE /api/outgoing-webhooks/{id}`, `POST …/test` |
|
|
| Git | `GET /api/projects/{project}/git-integration`, `POST …/git-integration/repositories`, `POST …/repositories/{id}/secret`, `DELETE …/repositories/{id}`, `POST /api/webhooks/git/{project}` (lo llama el host de Git) |
|
|
| Alertas | `GET/PUT/DELETE /api/projects/{project}/alert-integration`, `POST …/alert-integration/token`, `POST /api/webhooks/alerts/{project}/{token}` |
|
|
| Explorar | `GET /api/explore/projects/{project}/showcase` (sin token, proyectos públicos): hoja de ruta con votos, versiones, sprint en curso, tickets `good-first-issue` y propuestas abiertas; `POST …/proposals` (sin token) |
|
|
| Sistema | `GET /api/health`, `GET /api/auth/config`, `POST /api/auth/token`, `POST /api/webhooks/pireon` |
|
|
|
|
Los archivos van por REST porque GraphQL no está hecho para bytes; la lista de adjuntos de un ticket llega con el
|
|
ticket (`attachments`, en GraphQL y en REST). La descarga sirve en línea solo imágenes, vídeo, audio, PDF y texto
|
|
plano, y todo con `X-Content-Type-Options: nosniff` y una CSP `sandbox`: un HTML o un SVG subido se descarga,
|
|
nunca se ejecuta en el origen de la API.
|
|
|
|
Los filtros de tickets aceptan `status` (ids del proyecto) y `category` (`?category=QA`), repetidos o separados por
|
|
comas. Las escrituras responden `{ id, key?, version }` en cuanto el evento se guarda; las lecturas salen del read model, que
|
|
se pone al día en milisegundos (un `GET` justo después puede devolver la versión anterior, o 404 tras crear). Para
|
|
tiempo real, las subscriptions de GraphQL.
|
|
|
|
### Errores
|
|
|
|
`{ statusCode, code?, message }`: `400` entrada no válida (`message` es una lista), `401` sin token o token rechazado
|
|
por Pireon, `403`/`404` sin acceso, `409` cambio concurrente (la API ya lo ha reintentado 5 veces; raro) y `422` regla de negocio (p. ej. una
|
|
transición que el workflow no permite).
|
|
|
|
### Generar un cliente en otra app
|
|
|
|
```sh
|
|
npx openapi-typescript apps/api/openapi.json -o src/task-stream-api.d.ts # tipos
|
|
# o un cliente completo: openapi-generator, orval, ng-openapi-gen…
|
|
```
|
|
|
|
Ver también [Autenticación](05-autenticacion.md).
|