feat: import and export tickets as CSV

Every ticket of a project can be downloaded as CSV, and a CSV exported
from TaskStream or from Jira can be imported: types, statuses, priorities
and people are matched by id or name, the rest falls back with a warning,
and parents are rebuilt. The settings page previews an import before it
creates anything.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-08 15:43:02 +02:00
parent d43fc620e9
commit 1076205acc
24 changed files with 1666 additions and 31 deletions

View File

@@ -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"
]
}
}
}

View File

@@ -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<string, never>;
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"];
};
};
};
};
}

View File

@@ -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

View File

@@ -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` |

View File

@@ -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';

View File

@@ -49,6 +49,7 @@ export class CreateIssueCommandHandler
title: command.title,
reporterId: command.actorId,
assigneeId: command.assigneeId,
status: command.initialStatus,
},
workflow,
);

View File

@@ -14,6 +14,8 @@ export class CreateIssueCommand extends Command<CommandResult> {
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();
}

View File

@@ -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,
})

View File

@@ -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<string> {
return this.queryBus.execute(new ExportIssuesQuery(viewerId, projectId));
}
importCsv(
actorId: string,
projectId: string,
csv: string,
dryRun: boolean,
): Promise<ImportReport> {
return this.commandBus.execute(
new ImportIssuesCommand(actorId, projectId, csv, dryRun),
);
}
}

View File

@@ -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'
);
}

View File

@@ -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<ImportReport> {
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<string> {
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<ImportIssuesCommand>
{
constructor(
private readonly access: WriteAccess,
private readonly findProjects: FindProjectsRepository,
private readonly commandBus: CommandBus,
) {}
async execute(command: ImportIssuesCommand): Promise<ImportReport> {
(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<string, string>();
const ids = new Map<number, string>();
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<ExportIssuesQuery, string>
{
constructor(
private readonly access: ReadAccess,
private readonly findProjects: FindProjectsRepository,
private readonly findIssues: FindIssuesRepository,
) {}
async execute(query: ExportIssuesQuery): Promise<string> {
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,
];

View File

@@ -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',
});
});
});

View File

@@ -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<string, IssuePriority> = {
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<string, string> = {
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<string, StatusCategory> = {
'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 = <T extends { id: string; name: string }>(
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;
}

View File

@@ -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,

View File

@@ -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,

View File

@@ -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;

View File

@@ -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<StreamableFile> {
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,
);
}
}

View File

@@ -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,

View File

@@ -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;

View File

@@ -6,6 +6,7 @@ import {
Comment,
Epic,
GitIntegration,
ImportReport,
Webhook,
WebhookDelivery,
SavedFilter,
@@ -133,6 +134,17 @@ export abstract class IssuesGateway {
): Observable<CommandResult>;
}
/** Tickets in and out as CSV. */
export abstract class TransferGateway {
abstract exportCsv(projectId: string): Observable<Blob>;
/** `dryRun`: only tell what would be created. */
abstract importCsv(
projectId: string,
file: File,
dryRun: boolean,
): Observable<ImportReport>;
}
/** The project's outgoing webhooks (owners and admins). */
export abstract class WebhooksGateway {
abstract list(projectId: string): Observable<Webhook[]>;

View File

@@ -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<Blob> {
return this.authorized((headers) =>
this.http.get(`${this.base(projectId)}/export.csv`, {
headers,
responseType: 'blob',
}),
);
}
importCsv(
projectId: string,
file: File,
dryRun: boolean,
): Observable<ImportReport> {
const body = new FormData();
body.append('file', file, file.name);
return this.authorized((headers) =>
this.http.post<ImportReport>(`${this.base(projectId)}/import`, body, {
headers,
params: { dryRun: String(dryRun) },
}),
);
}
private base(projectId: string): string {
return `${this.api.restUrl}/projects/${encodeURIComponent(projectId)}`;
}
private authorized<T>(
request: (headers: HttpHeaders) => Observable<T>,
): Observable<T> {
return from(this.tokens.accessToken()).pipe(
switchMap((token) =>
request(
token
? new HttpHeaders({ Authorization: `Bearer ${token}` })
: new HttpHeaders(),
),
),
);
}
}

View File

@@ -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 },
]);
}

View File

@@ -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"
/>
<ts-webhooks [projectId]="project.id" />
<ts-transfer [projectId]="project.id" [projectKey]="project.key" />
}
@if (facade.isOwner() && facade.project(); as project) {

View File

@@ -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: `
<section
class="ts-panel ts-block ts-transfer"
aria-labelledby="transfer-title"
>
<div>
<h3 id="transfer-title">Importar y exportar</h3>
<p class="ts-muted">
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.
</p>
</div>
<div>
<pi-action-button
size="small"
color="grey"
appearance="outline"
[text]="exporting() ? 'Exportando…' : 'Exportar CSV'"
[disabled]="exporting()"
(execute)="exportCsv()"
/>
</div>
<pi-file-upload
label="CSV para importar"
hint="Exportación de TaskStream o de Jira («Exportar → CSV (todos los campos)»)"
accept=".csv,text/csv"
[multiple]="false"
[maxFiles]="1"
[maxSizeBytes]="maxBytes"
[(value)]="files"
(valueChange)="preview()"
/>
@if (report(); as plan) {
<div class="ts-transfer__report" role="status">
<p>
@if (plan.dryRun) {
Formato {{ plan.format === 'jira' ? 'Jira' : 'TaskStream' }}: se
crearán <strong>{{ plan.rows }}</strong> tickets.
} @else {
Creados <strong>{{ plan.created }}</strong> tickets
@if (plan.keys.length) {
({{ plan.keys[0] }}
@if (plan.keys.length > 1) {
… {{ plan.keys[plan.keys.length - 1] }}
}
).
}
}
</p>
@if (plan.skipped.length) {
<p class="ts-transfer__bad">
{{ plan.skipped.length }} fila(s) fuera:
{{ lines(plan.skipped, 'reason') }}
</p>
}
@if (plan.warnings.length) {
<details>
<summary>{{ plan.warnings.length }} aviso(s)</summary>
<ul>
@for (warning of plan.warnings.slice(0, 30); track $index) {
<li>Línea {{ warning.line }}: {{ warning.message }}</li>
}
</ul>
</details>
}
@if (plan.dryRun && plan.rows) {
<pi-action-button
size="small"
color="primary"
[text]="
importing()
? 'Importando…'
: 'Importar ' + plan.rows + ' tickets'
"
[disabled]="importing()"
(execute)="importCsv()"
/>
}
</div>
}
</section>
`,
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<string>();
readonly projectKey = input.required<string>();
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<File[]>([]);
protected readonly report = signal<ImportReport | null>(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');
},
});
}
}