diff --git a/apps/api/openapi.json b/apps/api/openapi.json index c66a27f..4ef4d23 100644 --- a/apps/api/openapi.json +++ b/apps/api/openapi.json @@ -8399,6 +8399,217 @@ "Webhooks" ] } + }, + "/api/projects/{project}/export.csv": { + "get": { + "description": "UTF-8 with BOM. Cells that a spreadsheet would run as a formula start with a quote mark.", + "operationId": "TransferController_exportCsv", + "parameters": [ + { + "name": "project", + "required": true, + "in": "path", + "description": "Project id or key", + "schema": { + "example": "WEB", + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "The CSV file" + }, + "400": { + "description": "Invalid input", + "content": { + "text/csv": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "401": { + "description": "Missing or rejected Pireon token", + "content": { + "text/csv": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "403": { + "description": "Not a member of the project (or not allowed)", + "content": { + "text/csv": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "text/csv": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + } + }, + "security": [ + { + "pireon-oauth": [] + }, + { + "pireon": [] + }, + {} + ], + "summary": "Every ticket of the project as CSV", + "tags": [ + "Import and export" + ] + } + }, + "/api/projects/{project}/import": { + "post": { + "description": "TaskStream export or Jira CSV export, up to 5 MB and 2000 rows. Types, statuses and people are matched by id or name; the rest falls back with a warning. With dryRun=true nothing is created.", + "operationId": "TransferController_importCsv", + "parameters": [ + { + "name": "project", + "required": true, + "in": "path", + "description": "Project id or key", + "schema": { + "example": "WEB", + "type": "string" + } + }, + { + "name": "dryRun", + "required": false, + "in": "query", + "description": "Only tell what would be created", + "schema": { + "default": false, + "allOf": [ + { + "$ref": "#/components/schemas/Object" + } + ] + } + } + ], + "requestBody": { + "required": true, + "content": { + "multipart/form-data": { + "schema": { + "type": "object", + "properties": { + "file": { + "type": "string", + "format": "binary" + } + }, + "required": [ + "file" + ] + } + } + } + }, + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ImportReportDto" + } + } + } + }, + "400": { + "description": "Invalid input", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "401": { + "description": "Missing or rejected Pireon token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "403": { + "description": "Not a member of the project (or not allowed)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "409": { + "description": "Someone changed it at the same time: retry", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "422": { + "description": "Business rule (e.g. a transition the workflow forbids)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + } + }, + "security": [ + { + "pireon-oauth": [] + }, + { + "pireon": [] + } + ], + "summary": "Create tickets from a CSV (owners and admins)", + "tags": [ + "Import and export" + ] + } } }, "info": { @@ -11491,6 +11702,78 @@ "description": "Paused webhooks get nothing" } } + }, + "ImportLineDto": { + "type": "object", + "properties": { + "line": { + "type": "number", + "description": "Line of the file (the header is 1)" + }, + "reason": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "line" + ] + }, + "ImportReportDto": { + "type": "object", + "properties": { + "format": { + "type": "string", + "enum": [ + "taskstream", + "jira" + ] + }, + "dryRun": { + "type": "boolean", + "description": "Nothing was created: a preview" + }, + "rows": { + "type": "number", + "description": "Rows that become tickets" + }, + "created": { + "type": "number" + }, + "keys": { + "example": [ + "WEB-41", + "WEB-42" + ], + "type": "array", + "items": { + "type": "string" + } + }, + "skipped": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ImportLineDto" + } + }, + "warnings": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ImportLineDto" + } + } + }, + "required": [ + "format", + "dryRun", + "rows", + "created", + "keys", + "skipped", + "warnings" + ] } } } diff --git a/apps/mcp/src/app/api/taskstream.schema.ts b/apps/mcp/src/app/api/taskstream.schema.ts index 29908d1..74c0686 100644 --- a/apps/mcp/src/app/api/taskstream.schema.ts +++ b/apps/mcp/src/app/api/taskstream.schema.ts @@ -1209,6 +1209,46 @@ export interface paths { patch?: never; trace?: never; }; + "/api/projects/{project}/export.csv": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Every ticket of the project as CSV + * @description UTF-8 with BOM. Cells that a spreadsheet would run as a formula start with a quote mark. + */ + get: operations["TransferController_exportCsv"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/projects/{project}/import": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Create tickets from a CSV (owners and admins) + * @description TaskStream export or Jira CSV export, up to 5 MB and 2000 rows. Types, statuses and people are matched by id or name; the rest falls back with a warning. With dryRun=true nothing is created. + */ + post: operations["TransferController_importCsv"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; } export type webhooks = Record; export interface components { @@ -2226,6 +2266,30 @@ export interface components { /** @description Paused webhooks get nothing */ active?: boolean; }; + ImportLineDto: { + /** @description Line of the file (the header is 1) */ + line: number; + reason?: string; + message?: string; + }; + ImportReportDto: { + /** @enum {string} */ + format: "taskstream" | "jira"; + /** @description Nothing was created: a preview */ + dryRun: boolean; + /** @description Rows that become tickets */ + rows: number; + created: number; + /** + * @example [ + * "WEB-41", + * "WEB-42" + * ] + */ + keys: string[]; + skipped: components["schemas"]["ImportLineDto"][]; + warnings: components["schemas"]["ImportLineDto"][]; + }; }; responses: never; parameters: never; @@ -8051,4 +8115,147 @@ export interface operations { }; }; }; + TransferController_exportCsv: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Project id or key */ + project: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description The CSV file */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Invalid input */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "text/csv": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Missing or rejected Pireon token */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "text/csv": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Not a member of the project (or not allowed) */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "text/csv": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "text/csv": components["schemas"]["ApiErrorDto"]; + }; + }; + }; + }; + TransferController_importCsv: { + parameters: { + query?: { + /** @description Only tell what would be created */ + dryRun?: components["schemas"]["Object"]; + }; + header?: never; + path: { + /** @description Project id or key */ + project: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "multipart/form-data": { + /** Format: binary */ + file: string; + }; + }; + }; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ImportReportDto"]; + }; + }; + /** @description Invalid input */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Missing or rejected Pireon token */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Not a member of the project (or not allowed) */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Someone changed it at the same time: retry */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Business rule (e.g. a transition the workflow forbids) */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + }; + }; } diff --git a/docs/01-vision-general.md b/docs/01-vision-general.md index 9daeeb0..ccb1079 100644 --- a/docs/01-vision-general.md +++ b/docs/01-vision-general.md @@ -2,36 +2,37 @@ ## Qué resuelve -| Necesidad | Cómo se resuelve | -| ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| 🐛 Bugs | Tickets de tipo `BUG` con prioridad, área y etiquetas | -| 🌍 Públicos | Un proyecto público se puede leer sin iniciar sesión (web, GraphQL y REST): tablero, tickets, comentarios, releases, actividad y estadísticas; «Explorar» los lista | -| 🗑️ Borrar | El propietario borra un proyecto confirmando su clave; tickets, comentarios, releases y enlaces desde otros proyectos se borran en cascada (sagas) y la clave queda libre | -| 🧩 Configuración | Cada proyecto tiene sus estados (con categoría, color y motivo obligatorio), transiciones, tipos de ticket y áreas; lo usado se archiva, no se borra | -| 🎨 Cambios de UI | Tipo `UI_CHANGE` con enlace al frame de Figma (validado contra `figma.com`) | -| ⚙️ Features | Tipo `FEATURE` siguiendo el workflow completo | -| 🧪 QA | Por defecto, `READY_FOR_QA → IN_QA → DONE / QA_FAILED`; fallar QA exige un motivo | -| 🚀 Releases | Versiones a las que se asignan tickets; no se publican con tickets sin terminar (salvo forzando) | -| 📝 Notas de versión | Borrador en Markdown con los tickets terminados por tipo, editable y copiable, también tras publicar | -| 👥 Asignaciones | Solo a miembros del proyecto; panel de carga por persona | -| 💬 Comentarios | Decisiones y explicaciones dentro del ticket, con `@menciones`; el autor edita, el admin modera | -| 🔎 Vistas | El tablero se filtra por texto, persona (o sin asignar), tipo, prioridad, área y release; los filtros se guardan como vistas propias o compartidas con el proyecto | -| 🚦 WIP y carriles | Límite de trabajo en curso por estado (la columna se marca al pasarse) y tablero agrupable por persona, épica, prioridad o tipo | -| 🎲 Estimación | Puntos por ticket (escala 0-1-2-3-5-8-13-21) en la ficha, las tarjetas, las columnas y el backlog; velocidad por sprint y aviso si un sprint planificado supera la media | -| 🏃 Sprints | Iteraciones con fechas y objetivo, una en curso por proyecto, con burndown (tickets o puntos) y traspaso de lo pendiente al cerrar | -| 📋 Backlog | Lo pendiente de empezar, en el orden en que se va a coger; se reordena arrastrando y el tablero sigue ese orden | -| 🗺️ Hoja de ruta | Las épicas en el tiempo (fecha de inicio y límite), con los sprints y las releases como referencias | -| 🧩 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; quien comenta, los sigue solo | -| 🔔 Avisos | Campana con lo que te concierne (asignaciones, cambios de estado, QA fallido, comentarios), en vivo y opcionalmente por correo | -| 📎 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 | -| 🔗 Relaciones | `BLOCKS`, `DEPENDS_ON`, `TESTS`, `DUPLICATES`, `RELATES_TO`, también entre proyectos (FE ↔ BE ↔ QA) | -| 🌿 Git | Webhook para GitHub, Gitea o GitLab: commits, ramas y pull requests que nombran un ticket (WEB-12) aparecen en su ficha | -| 📡 Webhooks | Un POST firmado (HMAC-SHA256) a otro sistema cuando pasa algo: tickets, comentarios, releases, sprints; con prueba y último envío | -| 🏠 Inicio | «Mi trabajo»: mis tickets en todos los proyectos, por categoría (QA fallidos primero), lo que espera QA, próximas releases e «Ir a…» por clave | -| 📊 Seguimiento | Panel con contadores, flujo por estado, el equipo con sus tickets abiertos (enlaza a su tablero), feed de actividad e historial por ticket | -| 📈 Estadísticas | Creados vs. resueltos, flujo acumulado, burnup de releases, tiempos de ciclo y entrega, tasa de fallo en QA y resueltos por persona | -| ⚡ Tiempo real | Tablero, comentarios, actividad, releases y membresías se actualizan solos (GraphQL subscriptions) | +| Necesidad | Cómo se resuelve | +| -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 🐛 Bugs | Tickets de tipo `BUG` con prioridad, área y etiquetas | +| 🌍 Públicos | Un proyecto público se puede leer sin iniciar sesión (web, GraphQL y REST): tablero, tickets, comentarios, releases, actividad y estadísticas; «Explorar» los lista | +| 🗑️ Borrar | El propietario borra un proyecto confirmando su clave; tickets, comentarios, releases y enlaces desde otros proyectos se borran en cascada (sagas) y la clave queda libre | +| 🧩 Configuración | Cada proyecto tiene sus estados (con categoría, color y motivo obligatorio), transiciones, tipos de ticket y áreas; lo usado se archiva, no se borra | +| 🎨 Cambios de UI | Tipo `UI_CHANGE` con enlace al frame de Figma (validado contra `figma.com`) | +| ⚙️ Features | Tipo `FEATURE` siguiendo el workflow completo | +| 🧪 QA | Por defecto, `READY_FOR_QA → IN_QA → DONE / QA_FAILED`; fallar QA exige un motivo | +| 🚀 Releases | Versiones a las que se asignan tickets; no se publican con tickets sin terminar (salvo forzando) | +| 📝 Notas de versión | Borrador en Markdown con los tickets terminados por tipo, editable y copiable, también tras publicar | +| 👥 Asignaciones | Solo a miembros del proyecto; panel de carga por persona | +| 💬 Comentarios | Decisiones y explicaciones dentro del ticket, con `@menciones`; el autor edita, el admin modera | +| 🔎 Vistas | El tablero se filtra por texto, persona (o sin asignar), tipo, prioridad, área y release; los filtros se guardan como vistas propias o compartidas con el proyecto | +| 🚦 WIP y carriles | Límite de trabajo en curso por estado (la columna se marca al pasarse) y tablero agrupable por persona, épica, prioridad o tipo | +| 🎲 Estimación | Puntos por ticket (escala 0-1-2-3-5-8-13-21) en la ficha, las tarjetas, las columnas y el backlog; velocidad por sprint y aviso si un sprint planificado supera la media | +| 🏃 Sprints | Iteraciones con fechas y objetivo, una en curso por proyecto, con burndown (tickets o puntos) y traspaso de lo pendiente al cerrar | +| 📋 Backlog | Lo pendiente de empezar, en el orden en que se va a coger; se reordena arrastrando y el tablero sigue ese orden | +| 🗺️ Hoja de ruta | Las épicas en el tiempo (fecha de inicio y límite), con los sprints y las releases como referencias | +| 🧩 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; quien comenta, los sigue solo | +| 🔔 Avisos | Campana con lo que te concierne (asignaciones, cambios de estado, QA fallido, comentarios), en vivo y opcionalmente por correo | +| 📎 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 | +| 🔗 Relaciones | `BLOCKS`, `DEPENDS_ON`, `TESTS`, `DUPLICATES`, `RELATES_TO`, también entre proyectos (FE ↔ BE ↔ QA) | +| 🌿 Git | Webhook para GitHub, Gitea o GitLab: commits, ramas y pull requests que nombran un ticket (WEB-12) aparecen en su ficha | +| 📡 Webhooks | Un POST firmado (HMAC-SHA256) a otro sistema cuando pasa algo: tickets, comentarios, releases, sprints; con prueba y último envío | +| 📦 Importar/exportar | Todos los tickets a CSV; importar un CSV propio o una exportación de Jira, con vista previa de lo que se va a crear | +| 🏠 Inicio | «Mi trabajo»: mis tickets en todos los proyectos, por categoría (QA fallidos primero), lo que espera QA, próximas releases e «Ir a…» por clave | +| 📊 Seguimiento | Panel con contadores, flujo por estado, el equipo con sus tickets abiertos (enlaza a su tablero), feed de actividad e historial por ticket | +| 📈 Estadísticas | Creados vs. resueltos, flujo acumulado, burnup de releases, tiempos de ciclo y entrega, tasa de fallo en QA y resueltos por persona | +| ⚡ Tiempo real | Tablero, comentarios, actividad, releases y membresías se actualizan solos (GraphQL subscriptions) | Los **usuarios no se gestionan aquí**: vienen del servidor de identidad **Pireon** ([Swagger](https://oauth.pireon.dev/api/documentation)). TaskStream gestiona proyectos, membresías y roles por diff --git a/docs/03-api.md b/docs/03-api.md index a551b89..99dead2 100644 --- a/docs/03-api.md +++ b/docs/03-api.md @@ -175,6 +175,13 @@ stateDiagram-v2 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. @@ -499,6 +506,7 @@ Proyectos y tickets se pueden indicar por **id o por clave** (`WEB`, `WEB-12`). | 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) | +| 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/POST/DELETE /api/projects/{project}/git-integration`, `POST /api/webhooks/git/{project}` (lo llama el host de Git) | | Sistema | `GET /api/health`, `GET /api/auth/config`, `POST /api/auth/token`, `POST /api/webhooks/pireon` | diff --git a/libs/api/tracker/application/src/index.ts b/libs/api/tracker/application/src/index.ts index 08cf93c..d2a2196 100644 --- a/libs/api/tracker/application/src/index.ts +++ b/libs/api/tracker/application/src/index.ts @@ -13,6 +13,8 @@ export * from './lib/saved-filters.service'; export * from './lib/sprints.service'; export * from './lib/git.service'; export * from './lib/webhooks.service'; +export * from './lib/transfer.service'; +export * from './lib/transfer/import-export'; export * from './lib/webhooks/webhooks'; export * from './lib/webhooks/webhook-events'; export * from './lib/git/git'; diff --git a/libs/api/tracker/application/src/lib/commands/create-issue.command-handler.ts b/libs/api/tracker/application/src/lib/commands/create-issue.command-handler.ts index ec62248..edb1d10 100644 --- a/libs/api/tracker/application/src/lib/commands/create-issue.command-handler.ts +++ b/libs/api/tracker/application/src/lib/commands/create-issue.command-handler.ts @@ -49,6 +49,7 @@ export class CreateIssueCommandHandler title: command.title, reporterId: command.actorId, assigneeId: command.assigneeId, + status: command.initialStatus, }, workflow, ); diff --git a/libs/api/tracker/application/src/lib/commands/create-issue.command.ts b/libs/api/tracker/application/src/lib/commands/create-issue.command.ts index 83cb00c..bfb22c5 100644 --- a/libs/api/tracker/application/src/lib/commands/create-issue.command.ts +++ b/libs/api/tracker/application/src/lib/commands/create-issue.command.ts @@ -14,6 +14,8 @@ export class CreateIssueCommand extends Command { public readonly releaseId?: string | null, /** Epic or ticket the new one breaks down. */ public readonly parentId?: string | null, + /** Imports only: start in this (active) status instead of the first one. */ + public readonly initialStatus?: string | null, ) { super(); } diff --git a/libs/api/tracker/application/src/lib/tracker-application.module.ts b/libs/api/tracker/application/src/lib/tracker-application.module.ts index 70dfca6..3d84e7d 100644 --- a/libs/api/tracker/application/src/lib/tracker-application.module.ts +++ b/libs/api/tracker/application/src/lib/tracker-application.module.ts @@ -36,6 +36,8 @@ import { GitService } from './git.service'; import { GIT_HANDLERS } from './git/git'; import { WebhooksService } from './webhooks.service'; import { WEBHOOK_HANDLERS } from './webhooks/webhooks'; +import { TransferService } from './transfer.service'; +import { TRANSFER_HANDLERS } from './transfer/import-export'; /** Notifications: derived from events, kept and dismissed per person. */ const NOTIFICATIONS = [ @@ -59,6 +61,7 @@ const SERVICES = [ SprintsService, GitService, WebhooksService, + TransferService, ]; /** @@ -83,6 +86,7 @@ const SERVICES = [ ...SAVED_FILTERS, ...GIT_HANDLERS, ...WEBHOOK_HANDLERS, + ...TRANSFER_HANDLERS, ], exports: SERVICES, }) diff --git a/libs/api/tracker/application/src/lib/transfer.service.ts b/libs/api/tracker/application/src/lib/transfer.service.ts new file mode 100644 index 0000000..4eca8a9 --- /dev/null +++ b/libs/api/tracker/application/src/lib/transfer.service.ts @@ -0,0 +1,31 @@ +import { Injectable } from '@nestjs/common'; +import { CommandBus, QueryBus } from '@nestjs/cqrs'; +import { + ExportIssuesQuery, + ImportIssuesCommand, + ImportReport, +} from './transfer/import-export'; + +/** Tickets in and out as CSV (TaskStream's own, or a Jira export). */ +@Injectable() +export class TransferService { + constructor( + private readonly commandBus: CommandBus, + private readonly queryBus: QueryBus, + ) {} + + exportCsv(viewerId: string | null, projectId: string): Promise { + return this.queryBus.execute(new ExportIssuesQuery(viewerId, projectId)); + } + + importCsv( + actorId: string, + projectId: string, + csv: string, + dryRun: boolean, + ): Promise { + return this.commandBus.execute( + new ImportIssuesCommand(actorId, projectId, csv, dryRun), + ); + } +} diff --git a/libs/api/tracker/application/src/lib/transfer/csv.ts b/libs/api/tracker/application/src/lib/transfer/csv.ts new file mode 100644 index 0000000..fdb927d --- /dev/null +++ b/libs/api/tracker/application/src/lib/transfer/csv.ts @@ -0,0 +1,52 @@ +/** RFC 4180 CSV: quotes, doubled quotes, line breaks inside fields, CRLF, BOM. */ +export function parseCsv(text: string): string[][] { + const rows: string[][] = []; + let row: string[] = []; + let field = ''; + let quoted = false; + const input = text.replace(/^\uFEFF/, ''); + for (let i = 0; i < input.length; i++) { + const char = input[i]; + if (quoted) { + if (char === '"' && input[i + 1] === '"') { + field += '"'; + i++; + } else if (char === '"') quoted = false; + else field += char; + continue; + } + if (char === '"') quoted = true; + else if (char === ',') { + row.push(field); + field = ''; + } else if (char === '\n' || char === '\r') { + if (char === '\r' && input[i + 1] === '\n') i++; + row.push(field); + rows.push(row); + row = []; + field = ''; + } else field += char; + } + if (field !== '' || row.length) { + row.push(field); + rows.push(row); + } + return rows.filter((r) => r.some((cell) => cell.trim() !== '')); +} + +/** + * Text that a spreadsheet would run as a formula starts with a quote mark + * instead: an export must not become a way to run code on someone's machine. + */ +function cell(value: string | number | null | undefined): string { + let text = value === null || value === undefined ? '' : String(value); + if (/^[=+\-@\t\r]/.test(text)) text = `'${text}`; + return /[",\n\r]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text; +} + +/** CSV with a BOM, so spreadsheets read it as UTF-8. */ +export function toCsv(rows: (string | number | null | undefined)[][]): string { + return ( + '\uFEFF' + rows.map((r) => r.map(cell).join(',')).join('\r\n') + '\r\n' + ); +} diff --git a/libs/api/tracker/application/src/lib/transfer/import-export.ts b/libs/api/tracker/application/src/lib/transfer/import-export.ts new file mode 100644 index 0000000..91f6636 --- /dev/null +++ b/libs/api/tracker/application/src/lib/transfer/import-export.ts @@ -0,0 +1,187 @@ +import { + Command, + CommandBus, + CommandHandler, + ICommandHandler, + IQueryHandler, + Query, + QueryHandler, +} from '@nestjs/cqrs'; +import { BusinessRuleError } from '@task-stream/api/shared/domain'; +import { + DEFAULT_PROJECT_SETTINGS, + ProjectWorkflow, +} from '@task-stream/api/tracker/domain'; +import { CommandResult } from '../commands/command-result'; +import { CreateIssueCommand } from '../commands/create-issue.command'; +import { SetIssueParentCommand } from '../commands/set-issue-parent.command'; +import { WriteAccess } from '../commands/write-access'; +import { FindIssuesRepository } from '../ports/find-issues.repository'; +import { FindProjectsRepository } from '../ports/find-projects.repository'; +import { ReadAccess } from '../queries/read-access'; +import { ImportFormat, exportIssuesCsv, planImport } from './transfer'; + +/** A file is at most this many megabytes. */ +export const MAX_IMPORT_MB = 5; + +export interface ImportReport { + format: ImportFormat; + dryRun: boolean; + /** Rows that become (or would become) tickets. */ + rows: number; + created: number; + /** Created tickets, in file order. */ + keys: string[]; + skipped: { line: number; reason: string }[]; + warnings: { line: number; message: string }[]; +} + +export class ImportIssuesCommand extends Command { + constructor( + public readonly actorId: string, + public readonly projectId: string, + public readonly csv: string, + /** Plan only: tell what would happen and create nothing. */ + public readonly dryRun: boolean, + ) { + super(); + } +} + +export class ExportIssuesQuery extends Query { + constructor( + public readonly viewerId: string | null, + public readonly projectId: string, + ) { + super(); + } +} + +/** + * Creates the tickets of a CSV one by one (each a normal ticket creation, with + * its own events), then places them under their parents. Owners and admins. + */ +@CommandHandler(ImportIssuesCommand) +export class ImportIssuesCommandHandler + implements ICommandHandler +{ + constructor( + private readonly access: WriteAccess, + private readonly findProjects: FindProjectsRepository, + private readonly commandBus: CommandBus, + ) {} + + async execute(command: ImportIssuesCommand): Promise { + (await this.access.project(command.projectId)).assertCanManage( + command.actorId, + ); + if (Buffer.byteLength(command.csv) > MAX_IMPORT_MB * 1024 * 1024) + throw new BusinessRuleError( + `The file is larger than ${MAX_IMPORT_MB} MB`, + ); + const project = await this.findProjects.findById(command.projectId); + if (!project) throw new BusinessRuleError('The project is not ready yet'); + const plan = planImport(command.csv, project); + const report: ImportReport = { + format: plan.format, + dryRun: command.dryRun, + rows: plan.rows.length, + created: 0, + keys: [], + skipped: [...plan.skipped], + warnings: [...plan.warnings], + }; + if (command.dryRun) return report; + + const created = new Map(); + const ids = new Map(); + for (const row of plan.rows) { + try { + const result: CommandResult = await this.commandBus.execute( + new CreateIssueCommand( + command.actorId, + project.id, + row.type, + row.title, + { + description: row.description, + priority: row.priority, + labels: row.labels, + storyPoints: row.storyPoints, + startDate: row.startDate, + dueDate: row.dueDate, + }, + row.assigneeId, + null, + null, + row.status, + ), + ); + report.created++; + report.keys.push(result.key ?? result.id); + ids.set(row.line, result.id); + for (const key of row.sourceKeys) created.set(key, result.id); + if (result.key) created.set(result.key, result.id); + } catch (error) { + report.skipped.push({ + line: row.line, + reason: error instanceof Error ? error.message : String(error), + }); + } + } + // Parents last: they may come after their children in the file. + for (const row of plan.rows) { + const id = ids.get(row.line); + const parent = row.parentSource + ? created.get(row.parentSource) + : undefined; + if (!id || !row.parentSource) continue; + if (!parent) { + report.warnings.push({ + line: row.line, + message: `Su padre (${row.parentSource}) no está en el archivo`, + }); + continue; + } + try { + await this.commandBus.execute( + new SetIssueParentCommand(command.actorId, id, parent), + ); + } catch (error) { + report.warnings.push({ + line: row.line, + message: `No cuelga de ${row.parentSource}: ${error instanceof Error ? error.message : error}`, + }); + } + } + return report; + } +} + +@QueryHandler(ExportIssuesQuery) +export class ExportIssuesQueryHandler + implements IQueryHandler +{ + constructor( + private readonly access: ReadAccess, + private readonly findProjects: FindProjectsRepository, + private readonly findIssues: FindIssuesRepository, + ) {} + + async execute(query: ExportIssuesQuery): Promise { + await this.access.assertCanView(query.projectId, query.viewerId); + const [project, issues] = await Promise.all([ + this.findProjects.findById(query.projectId), + this.findIssues.findByProject(query.projectId), + ]); + return exportIssuesCsv( + issues, + new ProjectWorkflow(project?.settings ?? DEFAULT_PROJECT_SETTINGS), + ); + } +} + +export const TRANSFER_HANDLERS = [ + ImportIssuesCommandHandler, + ExportIssuesQueryHandler, +]; diff --git a/libs/api/tracker/application/src/lib/transfer/transfer.spec.ts b/libs/api/tracker/application/src/lib/transfer/transfer.spec.ts new file mode 100644 index 0000000..5ce6a83 --- /dev/null +++ b/libs/api/tracker/application/src/lib/transfer/transfer.spec.ts @@ -0,0 +1,85 @@ +import { + defaultProjectSettings, + IssuePriority, + ProjectReadModel, +} from '@task-stream/api/tracker/domain'; +import { parseCsv, toCsv } from './csv'; +import { dayFrom, planImport } from './transfer'; + +const project = { + id: 'p1', + key: 'WEB', + settings: defaultProjectSettings(), + members: [{ user: { id: 'u-ana', name: 'Ana García' }, role: 'MEMBER' }], +} as unknown as ProjectReadModel; + +describe('CSV', () => { + it('round-trips quotes, commas and line breaks, and defuses formulas', () => { + const csv = toCsv([ + ['a', 'b'], + ['say "hi", ok', 'two\nlines'], + ['=HYPERLINK("x")', 3], + ]); + expect(parseCsv(csv)).toEqual([ + ['a', 'b'], + ['say "hi", ok', 'two\nlines'], + ['\'=HYPERLINK("x")', '3'], + ]); + }); + + it('reads Jira dates', () => { + expect(dayFrom('14/Nov/26 12:00 AM')).toBe('2026-11-14'); + expect(dayFrom('2026-11-14T10:00:00Z')).toBe('2026-11-14'); + expect(dayFrom('mañana')).toBeNull(); + }); +}); + +describe('import plan', () => { + it('maps a Jira export: types, statuses, priority, people, repeated labels', () => { + const csv = [ + 'Summary,Issue key,Issue id,Issue Type,Status,Status Category,Priority,Assignee,Labels,Labels,Custom field (Story Points),Parent,Due date', + 'Pay by card,PAY-1,10001,Story,Review,In Progress,Highest,Ana García,ui,checkout,5,,14/Nov/26 12:00 AM', + 'Card form,PAY-2,10002,Sub-task,Whatever,,Low,Nobody,,,x,10001,', + ',PAY-3,10003,Bug,To Do,To Do,,,,,,,', + ].join('\n'); + const plan = planImport(csv, project); + + expect(plan.format).toBe('jira'); + expect(plan.rows).toHaveLength(2); + expect(plan.rows[0]).toMatchObject({ + type: 'FEATURE', + status: 'IN_PROGRESS', + priority: IssuePriority.CRITICAL, + assigneeId: 'u-ana', + labels: ['ui', 'checkout'], + storyPoints: 5, + dueDate: '2026-11-14', + sourceKeys: ['PAY-1', '10001'], + }); + expect(plan.rows[1]).toMatchObject({ + type: 'TASK', + status: 'BACKLOG', + assigneeId: null, + storyPoints: null, + parentSource: '10001', + }); + expect(plan.skipped).toEqual([{ line: 4, reason: 'Sin título' }]); + expect(plan.warnings.map((w) => w.line)).toEqual([3, 3, 3]); + }); + + it('reads its own export back', () => { + const plan = planImport( + 'key,type,status,priority,title,assigneeId,labels,parent\nWEB-1,BUG,DONE,HIGH,Crash,u-ana,"a, b",WEB-9\n', + project, + ); + expect(plan.format).toBe('taskstream'); + expect(plan.rows[0]).toMatchObject({ + type: 'BUG', + status: 'DONE', + priority: IssuePriority.HIGH, + assigneeId: 'u-ana', + labels: ['a', 'b'], + parentSource: 'WEB-9', + }); + }); +}); diff --git a/libs/api/tracker/application/src/lib/transfer/transfer.ts b/libs/api/tracker/application/src/lib/transfer/transfer.ts new file mode 100644 index 0000000..3fd0931 --- /dev/null +++ b/libs/api/tracker/application/src/lib/transfer/transfer.ts @@ -0,0 +1,312 @@ +import { + IssuePriority, + IssueReadModel, + ProjectReadModel, + ProjectWorkflow, + StatusCategory, +} from '@task-stream/api/tracker/domain'; +import { parseCsv, toCsv } from './csv'; + +/** Columns of TaskStream's own export, also what an import of it reads. */ +export const EXPORT_COLUMNS = [ + 'key', + 'type', + 'typeName', + 'status', + 'statusName', + 'priority', + 'title', + 'description', + 'assignee', + 'assigneeId', + 'reporter', + 'labels', + 'storyPoints', + 'sprint', + 'release', + 'parent', + 'startDate', + 'dueDate', + 'createdAt', + 'updatedAt', +] as const; + +/** Every ticket of a project as CSV (by key). */ +export function exportIssuesCsv( + issues: IssueReadModel[], + workflow: ProjectWorkflow, +): string { + const rows = [...issues] + .sort((a, b) => a.number - b.number) + .map((i) => [ + i.key, + i.type, + workflow.typeName(i.type), + i.status, + workflow.statusName(i.status), + i.priority, + i.title, + i.description, + i.assignee?.name ?? '', + i.assignee?.id ?? '', + i.reporter?.name ?? '', + i.labels.join(', '), + i.storyPoints, + i.sprint?.name ?? '', + i.release?.name ?? '', + i.parent?.key ?? '', + i.startDate, + i.dueDate, + i.createdAt, + i.updatedAt, + ]); + return toCsv([[...EXPORT_COLUMNS], ...rows]); +} + +export type ImportFormat = 'taskstream' | 'jira'; + +/** A row ready to become a ticket. */ +export interface ImportRow { + /** Number in the file (header is 1). */ + line: number; + /** Its key (or Jira id) in the source, to rebuild parents. */ + sourceKeys: string[]; + parentSource: string | null; + title: string; + description: string | null; + type: string; + status: string; + priority: IssuePriority; + assigneeId: string | null; + labels: string[]; + storyPoints: number | null; + startDate: string | null; + dueDate: string | null; +} + +export interface ImportPlan { + format: ImportFormat; + rows: ImportRow[]; + /** Rows left out, and why. */ + skipped: { line: number; reason: string }[]; + /** Guesses made (a type or a person not found…). */ + warnings: { line: number; message: string }[]; +} + +export const MAX_IMPORT_ROWS = 2000; + +const JIRA_PRIORITY: Record = { + highest: IssuePriority.CRITICAL, + blocker: IssuePriority.CRITICAL, + critical: IssuePriority.CRITICAL, + high: IssuePriority.HIGH, + major: IssuePriority.HIGH, + medium: IssuePriority.MEDIUM, + low: IssuePriority.LOW, + minor: IssuePriority.LOW, + lowest: IssuePriority.LOWEST, + trivial: IssuePriority.LOWEST, +}; + +/** Jira's types, by the default type they mean here. */ +const JIRA_TYPE: Record = { + bug: 'BUG', + defect: 'BUG', + story: 'FEATURE', + feature: 'FEATURE', + 'new feature': 'FEATURE', + improvement: 'FEATURE', + task: 'TASK', + 'sub-task': 'TASK', + subtask: 'TASK', + epic: 'TASK', + test: 'QA', +}; + +const JIRA_CATEGORY: Record = { + 'to do': StatusCategory.TODO, + 'in progress': StatusCategory.IN_PROGRESS, + done: StatusCategory.DONE, +}; + +const MONTHS = [ + 'jan', + 'feb', + 'mar', + 'apr', + 'may', + 'jun', + 'jul', + 'aug', + 'sep', + 'oct', + 'nov', + 'dec', +]; + +/** `YYYY-MM-DD` from ISO or Jira's `14/Nov/26 12:00 AM`; null if neither. */ +export function dayFrom(text: string): string | null { + const value = text.trim(); + if (!value) return null; + const iso = /^(\d{4}-\d{2}-\d{2})/.exec(value); + if (iso && !Number.isNaN(Date.parse(iso[1]))) return iso[1]; + const jira = /^(\d{1,2})\/([A-Za-z]{3})\/(\d{2,4})/.exec(value); + if (!jira) return null; + const month = MONTHS.indexOf(jira[2].toLowerCase()); + if (month === -1) return null; + const year = jira[3].length === 2 ? 2000 + Number(jira[3]) : Number(jira[3]); + return `${year}-${String(month + 1).padStart(2, '0')}-${jira[1].padStart(2, '0')}`; +} + +/** + * Reads a CSV exported from TaskStream or from Jira and plans the tickets it + * would create in `project`: types, statuses and people are matched by id or + * by name, and whatever cannot be matched falls back with a warning. + */ +export function planImport( + text: string, + project: ProjectReadModel, +): ImportPlan { + const workflow = new ProjectWorkflow(project.settings); + const [header = [], ...body] = parseCsv(text); + const names = header.map((h) => h.trim().toLowerCase()); + const format: ImportFormat = + names.includes('summary') && names.includes('issue key') + ? 'jira' + : 'taskstream'; + /** Values of every column called `name` (Jira repeats Labels, Sprint…). */ + const all = (row: string[], ...wanted: string[]) => + names + .map((n, i) => (wanted.includes(n) ? (row[i] ?? '').trim() : '')) + .filter(Boolean); + const one = (row: string[], ...wanted: string[]) => + all(row, ...wanted)[0] ?? ''; + + const types = workflow.settings.issueTypes.filter((t) => !t.archived); + const statuses = workflow.settings.statuses.filter((s) => !s.archived); + const byName = ( + list: T[], + value: string, + ) => + list.find( + (item) => + item.id.toLowerCase() === value.toLowerCase() || + item.name.toLowerCase() === value.toLowerCase(), + ); + const members = project.members.map((m) => m.user); + + const plan: ImportPlan = { format, rows: [], skipped: [], warnings: [] }; + body.slice(0, MAX_IMPORT_ROWS).forEach((row, index) => { + const line = index + 2; + const warn = (message: string) => plan.warnings.push({ line, message }); + const title = one(row, 'title', 'summary'); + if (!title) { + plan.skipped.push({ line, reason: 'Sin título' }); + return; + } + + const typeText = one(row, 'type', 'typename', 'issue type'); + let type = + byName(types, typeText)?.id ?? + byName(types, one(row, 'typename'))?.id ?? + (format === 'jira' + ? types.find((t) => t.id === JIRA_TYPE[typeText.toLowerCase()])?.id + : undefined); + if (!type) { + type = types[0].id; + if (typeText) + warn(`Tipo «${typeText}» desconocido: queda como ${types[0].name}`); + } + + const statusText = one(row, 'status', 'statusname'); + let status = + byName(statuses, statusText)?.id ?? + byName(statuses, one(row, 'statusname'))?.id; + if (!status && format === 'jira') { + const category = JIRA_CATEGORY[one(row, 'status category').toLowerCase()]; + status = statuses.find((s) => s.category === category)?.id; + } + if (!status) { + status = workflow.initialStatus(); + if (statusText) + warn( + `Estado «${statusText}» desconocido: empieza en ${workflow.statusName(status)}`, + ); + } + + const priorityText = one(row, 'priority').toLowerCase(); + const priority = (Object.values(IssuePriority) as string[]).includes( + priorityText.toUpperCase(), + ) + ? (priorityText.toUpperCase() as IssuePriority) + : (JIRA_PRIORITY[priorityText] ?? IssuePriority.MEDIUM); + + const assigneeText = one(row, 'assigneeid', 'assignee', 'assignee id'); + const assignee = assigneeText + ? members.find( + (u) => + u.id === assigneeText || + u.name.toLowerCase() === one(row, 'assignee').toLowerCase(), + ) + : undefined; + if (assigneeText && !assignee) + warn( + `«${one(row, 'assignee') || assigneeText}» no es miembro del proyecto: queda sin asignar`, + ); + + const pointsText = one( + row, + 'storypoints', + 'story points', + 'custom field (story points)', + 'custom field (story point estimate)', + ); + let storyPoints: number | null = null; + if (pointsText) { + const points = Number(pointsText.replace(',', '.')); + if (Number.isInteger(points) && points >= 0 && points <= 100) + storyPoints = points; + else warn(`Estimación «${pointsText}» no válida: queda sin estimar`); + } + + const labels = [ + ...new Set( + all(row, 'labels') + .flatMap((l) => l.split(/[,;\s]+/)) + .map((l) => l.trim().toLowerCase()) + .filter(Boolean), + ), + ].slice(0, 20); + + const dueText = one(row, 'duedate', 'due date'); + const dueDate = dayFrom(dueText); + if (dueText && !dueDate) warn(`Fecha límite «${dueText}» no reconocida`); + const startDate = dayFrom( + one(row, 'startdate', 'start date', 'custom field (start date)'), + ); + + plan.rows.push({ + line, + sourceKeys: [one(row, 'key', 'issue key'), one(row, 'issue id')].filter( + Boolean, + ), + parentSource: one(row, 'parent', 'parent id', 'parent key') || null, + title: title.slice(0, 255), + description: one(row, 'description') || null, + type, + status, + priority, + assigneeId: assignee?.id ?? null, + labels, + storyPoints, + startDate: startDate && dueDate && startDate > dueDate ? null : startDate, + dueDate, + }); + }); + if (body.length > MAX_IMPORT_ROWS) + plan.skipped.push({ + line: MAX_IMPORT_ROWS + 2, + reason: `Solo se importan ${MAX_IMPORT_ROWS} filas por archivo; sobran ${body.length - MAX_IMPORT_ROWS}`, + }); + return plan; +} diff --git a/libs/api/tracker/domain/src/lib/factories/issue.factory.ts b/libs/api/tracker/domain/src/lib/factories/issue.factory.ts index 2fc1c0e..af3568c 100644 --- a/libs/api/tracker/domain/src/lib/factories/issue.factory.ts +++ b/libs/api/tracker/domain/src/lib/factories/issue.factory.ts @@ -18,6 +18,17 @@ export interface CreateIssueProps extends IssueDetails { type: string; reporterId: string; assigneeId?: string | null; + /** Imports only: an active status of the workflow to start in. */ + status?: string | null; +} + +/** The status asked for if it is active in the workflow, else the first one. */ +function startingStatus( + status: string | null | undefined, + workflow: ProjectWorkflow, +): string { + const asked = status ? workflow.status(status) : undefined; + return asked && !asked.archived ? asked.id : workflow.initialStatus(); } @Injectable() @@ -51,7 +62,7 @@ export class IssueFactory { area: props.area ?? null, title, description: details.description ?? null, - status: workflow.initialStatus(), + status: startingStatus(props.status, workflow), priority: props.priority ?? IssuePriority.MEDIUM, reporterId: props.reporterId, assigneeId: props.assigneeId ?? null, diff --git a/libs/api/tracker/presenters/src/lib/http/dto/requests.dto.ts b/libs/api/tracker/presenters/src/lib/http/dto/requests.dto.ts index 4f3bc55..611154e 100644 --- a/libs/api/tracker/presenters/src/lib/http/dto/requests.dto.ts +++ b/libs/api/tracker/presenters/src/lib/http/dto/requests.dto.ts @@ -754,6 +754,17 @@ export class UpdateWebhookDto extends PartialType(CreateWebhookDto) { active?: boolean; } +export class ImportQueryDto { + @ApiPropertyOptional({ + default: false, + description: 'Only tell what would be created', + }) + @IsOptional() + @Transform(toBoolean) + @IsBoolean() + dryRun = false; +} + export class RankIssueDto { @ApiPropertyOptional({ type: String, diff --git a/libs/api/tracker/presenters/src/lib/http/dto/responses.dto.ts b/libs/api/tracker/presenters/src/lib/http/dto/responses.dto.ts index 462703f..2d23dae 100644 --- a/libs/api/tracker/presenters/src/lib/http/dto/responses.dto.ts +++ b/libs/api/tracker/presenters/src/lib/http/dto/responses.dto.ts @@ -194,6 +194,25 @@ export class WebhookCreatedDto { secret!: string; } +export class ImportLineDto { + @ApiProperty({ description: 'Line of the file (the header is 1)' }) + line!: number; + @ApiPropertyOptional() reason?: string; + @ApiPropertyOptional() message?: string; +} + +export class ImportReportDto { + @ApiProperty({ enum: ['taskstream', 'jira'] }) format!: string; + @ApiProperty({ description: 'Nothing was created: a preview' }) + dryRun!: boolean; + @ApiProperty({ description: 'Rows that become tickets' }) rows!: number; + @ApiProperty() created!: number; + @ApiProperty({ type: [String], example: ['WEB-41', 'WEB-42'] }) + keys!: string[]; + @ApiProperty({ type: [ImportLineDto] }) skipped!: ImportLineDto[]; + @ApiProperty({ type: [ImportLineDto] }) warnings!: ImportLineDto[]; +} + export class GitDeliveryResultDto { @ApiProperty({ description: 'Tickets that got something new' }) recorded!: number; diff --git a/libs/api/tracker/presenters/src/lib/http/transfer.controller.ts b/libs/api/tracker/presenters/src/lib/http/transfer.controller.ts new file mode 100644 index 0000000..29cac5a --- /dev/null +++ b/libs/api/tracker/presenters/src/lib/http/transfer.controller.ts @@ -0,0 +1,117 @@ +import { + Controller, + Get, + HttpCode, + Param, + Post, + Query, + Res, + StreamableFile, + UploadedFile, + UseInterceptors, +} from '@nestjs/common'; +import { FileInterceptor } from '@nestjs/platform-express'; +import { + ApiBody, + ApiConsumes, + ApiOkResponse, + ApiOperation, + ApiParam, + ApiProduces, + ApiTags, +} from '@nestjs/swagger'; +import { User } from '@task-stream/api/identity/domain'; +import { + ApiOptionalPireonAuth, + ApiPireonAuth, + CurrentUser, +} from '@task-stream/api/identity/presenters'; +import { ValidationError } from '@task-stream/api/shared/domain'; +import { + ApiCommandErrors, + ApiReadErrors, +} from '@task-stream/api/shared/presenters'; +import { TransferService } from '@task-stream/api/tracker/application'; +import { Response } from 'express'; +import { viewerOf } from '../common/viewer'; +import { ImportQueryDto } from './dto/requests.dto'; +import { ImportReportDto } from './dto/responses.dto'; +import { Refs } from './refs'; + +const PROJECT = ApiParam({ + name: 'project', + description: 'Project id or key', + example: 'WEB', +}); + +/** Tickets in and out as CSV: TaskStream's own, or Jira's export. */ +@ApiTags('Import and export') +@ApiPireonAuth() +@Controller('projects/:project') +export class TransferController { + constructor( + private readonly transfer: TransferService, + private readonly refs: Refs, + ) {} + + @Get('export.csv') + @ApiOptionalPireonAuth() + @PROJECT + @ApiProduces('text/csv') + @ApiOperation({ + summary: 'Every ticket of the project as CSV', + description: + 'UTF-8 with BOM. Cells that a spreadsheet would run as a formula start with a quote mark.', + }) + @ApiOkResponse({ description: 'The CSV file' }) + @ApiReadErrors() + async exportCsv( + @CurrentUser() me: User | undefined, + @Param('project') project: string, + @Res({ passthrough: true }) res: Response, + ): Promise { + const viewer = viewerOf(me); + const projectId = await this.refs.projectId(viewer, project); + const csv = await this.transfer.exportCsv(viewer, projectId); + res.set({ + 'Content-Type': 'text/csv; charset=utf-8', + 'Content-Disposition': `attachment; filename="${project.replace(/[^\w-]/g, '') || 'tickets'}.csv"`, + 'X-Content-Type-Options': 'nosniff', + }); + return new StreamableFile(Buffer.from(csv, 'utf8')); + } + + @Post('import') + @HttpCode(200) + @PROJECT + @UseInterceptors(FileInterceptor('file')) + @ApiConsumes('multipart/form-data') + @ApiBody({ + schema: { + type: 'object', + properties: { file: { type: 'string', format: 'binary' } }, + required: ['file'], + }, + }) + @ApiOperation({ + summary: 'Create tickets from a CSV (owners and admins)', + description: + 'TaskStream export or Jira CSV export, up to 5 MB and 2000 rows. Types, statuses and people are matched by id or name; the rest falls back with a warning. With dryRun=true nothing is created.', + }) + @ApiOkResponse({ type: ImportReportDto }) + @ApiCommandErrors() + async importCsv( + @CurrentUser() me: User, + @Param('project') project: string, + @UploadedFile() file: { buffer: Buffer } | undefined, + @Query() query: ImportQueryDto, + ) { + if (!file) throw new ValidationError('Send the CSV as `file`'); + return this.transfer.importCsv( + me.id, + await this.refs.projectId(me.id, project), + file.buffer.toString('utf8'), + query.dryRun, + ); + } +} diff --git a/libs/api/tracker/presenters/src/lib/tracker-presenters.module.ts b/libs/api/tracker/presenters/src/lib/tracker-presenters.module.ts index 7d44eea..f313f89 100644 --- a/libs/api/tracker/presenters/src/lib/tracker-presenters.module.ts +++ b/libs/api/tracker/presenters/src/lib/tracker-presenters.module.ts @@ -24,6 +24,7 @@ import { GitController } from './http/git.controller'; import { GitResolver } from './graphql/git.resolver'; import { WebhookResolver } from './graphql/webhook.resolver'; import { WebhooksController } from './http/webhooks.controller'; +import { TransferController } from './http/transfer.controller'; /** * Driving adapters of the tracker context: GraphQL (queries, mutations, @@ -57,6 +58,7 @@ import { WebhooksController } from './http/webhooks.controller'; SprintsController, GitController, WebhooksController, + TransferController, ], providers: [ Refs, diff --git a/libs/web/tracker/domain/src/lib/models/models.ts b/libs/web/tracker/domain/src/lib/models/models.ts index 34762a1..a042146 100644 --- a/libs/web/tracker/domain/src/lib/models/models.ts +++ b/libs/web/tracker/domain/src/lib/models/models.ts @@ -191,6 +191,17 @@ export interface Webhook { lastDelivery: WebhookDelivery | null; } +/** What an import did (or, as a preview, would do). */ +export interface ImportReport { + format: 'taskstream' | 'jira'; + dryRun: boolean; + rows: number; + created: number; + keys: string[]; + skipped: { line: number; reason?: string }[]; + warnings: { line: number; message?: string }[]; +} + /** The project's webhook for GitHub, Gitea or GitLab. */ export interface GitIntegration { enabled: boolean; diff --git a/libs/web/tracker/domain/src/lib/ports/gateways.ts b/libs/web/tracker/domain/src/lib/ports/gateways.ts index 018bf6e..ec7faf8 100644 --- a/libs/web/tracker/domain/src/lib/ports/gateways.ts +++ b/libs/web/tracker/domain/src/lib/ports/gateways.ts @@ -6,6 +6,7 @@ import { Comment, Epic, GitIntegration, + ImportReport, Webhook, WebhookDelivery, SavedFilter, @@ -133,6 +134,17 @@ export abstract class IssuesGateway { ): Observable; } +/** Tickets in and out as CSV. */ +export abstract class TransferGateway { + abstract exportCsv(projectId: string): Observable; + /** `dryRun`: only tell what would be created. */ + abstract importCsv( + projectId: string, + file: File, + dryRun: boolean, + ): Observable; +} + /** The project's outgoing webhooks (owners and admins). */ export abstract class WebhooksGateway { abstract list(projectId: string): Observable; diff --git a/libs/web/tracker/infrastructure/src/lib/adapters/http-transfer.gateway.ts b/libs/web/tracker/infrastructure/src/lib/adapters/http-transfer.gateway.ts new file mode 100644 index 0000000..6c41670 --- /dev/null +++ b/libs/web/tracker/infrastructure/src/lib/adapters/http-transfer.gateway.ts @@ -0,0 +1,58 @@ +import { HttpClient, HttpHeaders } from '@angular/common/http'; +import { inject, Injectable } from '@angular/core'; +import { + AccessTokenSource, + API_CONFIG, +} from '@task-stream/web/shared/infrastructure'; +import { ImportReport, TransferGateway } from '@task-stream/web/tracker/domain'; +import { from, Observable, switchMap } from 'rxjs'; + +/** CSV files travel over REST, like attachments. */ +@Injectable() +export class HttpTransferGateway extends TransferGateway { + private readonly http = inject(HttpClient); + private readonly api = inject(API_CONFIG); + private readonly tokens = inject(AccessTokenSource); + + exportCsv(projectId: string): Observable { + return this.authorized((headers) => + this.http.get(`${this.base(projectId)}/export.csv`, { + headers, + responseType: 'blob', + }), + ); + } + + importCsv( + projectId: string, + file: File, + dryRun: boolean, + ): Observable { + const body = new FormData(); + body.append('file', file, file.name); + return this.authorized((headers) => + this.http.post(`${this.base(projectId)}/import`, body, { + headers, + params: { dryRun: String(dryRun) }, + }), + ); + } + + private base(projectId: string): string { + return `${this.api.restUrl}/projects/${encodeURIComponent(projectId)}`; + } + + private authorized( + request: (headers: HttpHeaders) => Observable, + ): Observable { + return from(this.tokens.accessToken()).pipe( + switchMap((token) => + request( + token + ? new HttpHeaders({ Authorization: `Bearer ${token}` }) + : new HttpHeaders(), + ), + ), + ); + } +} diff --git a/libs/web/tracker/infrastructure/src/lib/tracker-infrastructure.providers.ts b/libs/web/tracker/infrastructure/src/lib/tracker-infrastructure.providers.ts index 10c362e..dc18260 100644 --- a/libs/web/tracker/infrastructure/src/lib/tracker-infrastructure.providers.ts +++ b/libs/web/tracker/infrastructure/src/lib/tracker-infrastructure.providers.ts @@ -14,6 +14,7 @@ import { SprintsGateway, GitGateway, WebhooksGateway, + TransferGateway, TrackerRealtimeGateway, UsersGateway, } from '@task-stream/web/tracker/domain'; @@ -32,6 +33,7 @@ import { GraphqlSavedFiltersGateway } from './adapters/graphql-saved-filters.gat import { GraphqlSprintsGateway } from './adapters/graphql-sprints.gateway'; import { GraphqlGitGateway } from './adapters/graphql-git.gateway'; import { GraphqlWebhooksGateway } from './adapters/graphql-webhooks.gateway'; +import { HttpTransferGateway } from './adapters/http-transfer.gateway'; /** Ports → GraphQL adapters (and REST for files). */ export function provideTrackerInfrastructure(): EnvironmentProviders { @@ -49,5 +51,6 @@ export function provideTrackerInfrastructure(): EnvironmentProviders { { provide: SprintsGateway, useClass: GraphqlSprintsGateway }, { provide: GitGateway, useClass: GraphqlGitGateway }, { provide: WebhooksGateway, useClass: GraphqlWebhooksGateway }, + { provide: TransferGateway, useClass: HttpTransferGateway }, ]); } diff --git a/libs/web/tracker/presenters/src/lib/pages/settings.page.ts b/libs/web/tracker/presenters/src/lib/pages/settings.page.ts index b33abe8..e2c33ff 100644 --- a/libs/web/tracker/presenters/src/lib/pages/settings.page.ts +++ b/libs/web/tracker/presenters/src/lib/pages/settings.page.ts @@ -18,6 +18,7 @@ import { NgTemplateOutlet } from '@angular/common'; import { PiIconComponent, PiNoteComponent } from '@pi/ui'; import { GitIntegrationComponent } from '../ui/git-integration'; import { WebhooksComponent } from '../ui/webhooks'; +import { TransferComponent } from '../ui/transfer'; import { ProjectFacade } from '@task-stream/web/tracker/application'; import { ProjectSettings, @@ -55,6 +56,7 @@ function idFrom(name: string): string { imports: [ GitIntegrationComponent, WebhooksComponent, + TransferComponent, CdkDropList, CdkDrag, CdkDragHandle, @@ -471,6 +473,7 @@ function idFrom(name: string): string { [projectKey]="project.key" /> + } @if (facade.isOwner() && facade.project(); as project) { diff --git a/libs/web/tracker/presenters/src/lib/ui/transfer.ts b/libs/web/tracker/presenters/src/lib/ui/transfer.ts new file mode 100644 index 0000000..b0257fd --- /dev/null +++ b/libs/web/tracker/presenters/src/lib/ui/transfer.ts @@ -0,0 +1,213 @@ +import { + ChangeDetectionStrategy, + Component, + DestroyRef, + inject, + input, + signal, +} from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { PiActionButtonComponent, PiFileUploadComponent } from '@pi/ui'; +import { ToastService } from '@task-stream/web/shared/ui'; +import { ImportReport, TransferGateway } from '@task-stream/web/tracker/domain'; + +/** Settings card: export every ticket as CSV, or import a CSV (ours or Jira's). */ +@Component({ + selector: 'ts-transfer', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [PiActionButtonComponent, PiFileUploadComponent], + template: ` +
+
+

Importar y exportar

+

+ Exporta todos los tickets a CSV, o crea tickets desde un CSV de + TaskStream o una exportación CSV de Jira (hasta 2000 filas). Antes de + importar verás qué se va a crear. +

+
+
+ +
+ + + + @if (report(); as plan) { +
+

+ @if (plan.dryRun) { + Formato {{ plan.format === 'jira' ? 'Jira' : 'TaskStream' }}: se + crearán {{ plan.rows }} tickets. + } @else { + Creados {{ plan.created }} tickets + @if (plan.keys.length) { + ({{ plan.keys[0] }} + @if (plan.keys.length > 1) { + … {{ plan.keys[plan.keys.length - 1] }} + } + ). + } + } +

+ @if (plan.skipped.length) { +

+ {{ plan.skipped.length }} fila(s) fuera: + {{ lines(plan.skipped, 'reason') }} +

+ } + @if (plan.warnings.length) { +
+ {{ plan.warnings.length }} aviso(s) +
    + @for (warning of plan.warnings.slice(0, 30); track $index) { +
  • Línea {{ warning.line }}: {{ warning.message }}
  • + } +
+
+ } + @if (plan.dryRun && plan.rows) { + + } +
+ } +
+ `, + styles: ` + .ts-transfer { + display: grid; + gap: 0.75rem; + h3 { + margin: 0 0 0.25rem; + } + } + .ts-transfer__report { + display: grid; + gap: 0.5rem; + padding: 0.75rem; + border-radius: 0.5rem; + background: color-mix(in srgb, var(--ts-brand) 8%, transparent); + p, + ul { + margin: 0; + } + ul { + padding-left: 1.25rem; + font-size: 0.875rem; + } + } + .ts-transfer__bad { + color: var(--ts-c-red, var(--ts-ink)); + font-size: 0.875rem; + } + `, +}) +export class TransferComponent { + readonly projectId = input.required(); + readonly projectKey = input.required(); + + private readonly gateway = inject(TransferGateway); + private readonly toast = inject(ToastService); + private readonly destroyRef = inject(DestroyRef); + protected readonly maxBytes = 5 * 1024 * 1024; + protected readonly files = signal([]); + protected readonly report = signal(null); + protected readonly exporting = signal(false); + protected readonly importing = signal(false); + + protected lines( + items: { line: number; reason?: string }[], + field: 'reason', + ): string { + return items + .slice(0, 5) + .map((i) => `línea ${i.line} (${i[field]})`) + .join('; '); + } + + protected exportCsv(): void { + this.exporting.set(true); + this.gateway + .exportCsv(this.projectId()) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe({ + next: (blob) => { + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = `${this.projectKey()}-tickets.csv`; + link.click(); + URL.revokeObjectURL(url); + this.exporting.set(false); + }, + error: () => { + this.exporting.set(false); + this.toast.error('No se pudo exportar'); + }, + }); + } + + /** A dry run as soon as a file is chosen. */ + protected preview(): void { + const [file] = this.files(); + this.report.set(null); + if (!file) return; + this.run(file, true); + } + + protected importCsv(): void { + const [file] = this.files(); + if (file) this.run(file, false); + } + + private run(file: File, dryRun: boolean): void { + this.importing.set(!dryRun); + this.gateway + .importCsv(this.projectId(), file, dryRun) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe({ + next: (report) => { + this.report.set(report); + this.importing.set(false); + if (!dryRun) { + this.files.set([]); + this.toast.success(`${report.created} tickets importados`); + } + }, + error: (error) => { + this.importing.set(false); + const message = (error as { error?: { message?: string } })?.error + ?.message; + this.toast.error(message || 'No se pudo leer el CSV'); + }, + }); + } +}