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:
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
117
libs/api/tracker/presenters/src/lib/http/transfer.controller.ts
Normal file
117
libs/api/tracker/presenters/src/lib/http/transfer.controller.ts
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user