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:
@@ -49,6 +49,7 @@ export class CreateIssueCommandHandler
|
||||
title: command.title,
|
||||
reporterId: command.actorId,
|
||||
assigneeId: command.assigneeId,
|
||||
status: command.initialStatus,
|
||||
},
|
||||
workflow,
|
||||
);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
31
libs/api/tracker/application/src/lib/transfer.service.ts
Normal file
31
libs/api/tracker/application/src/lib/transfer.service.ts
Normal 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),
|
||||
);
|
||||
}
|
||||
}
|
||||
52
libs/api/tracker/application/src/lib/transfer/csv.ts
Normal file
52
libs/api/tracker/application/src/lib/transfer/csv.ts
Normal 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'
|
||||
);
|
||||
}
|
||||
187
libs/api/tracker/application/src/lib/transfer/import-export.ts
Normal file
187
libs/api/tracker/application/src/lib/transfer/import-export.ts
Normal 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,
|
||||
];
|
||||
@@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
312
libs/api/tracker/application/src/lib/transfer/transfer.ts
Normal file
312
libs/api/tracker/application/src/lib/transfer/transfer.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user