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

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