import { parse } from 'jsonc-parser'; import { readFileSync } from 'fs'; import { parseArgs } from 'util'; import { parse as csvParse } from 'csv/sync'; import { DateTime } from 'luxon'; import { createInterface } from 'readline/promises'; const helpMsg = ` Синхронизация задач из Redmine с Redmine Issue Event Emitter * --config=sync-issues.jsonc - конфигурационный файл для постановки задачи на выполнение синхронизации * --test - тестовый прогон без запуска задачи на синхронизацию * --yes - автоматически отвечать 'y' Пример конфигурационного файла: { "redmineUrl": "http://redmine.my-company.local", "redmineToken": "... ... ...", "eventEmitterUrl": "http://localhost:3000" "forceUpdate": true, // true - принудительное обновление, // false - сравнение даты обновления в redmine и в event-emitter-е // и обновление только просроченных задач "updatedAtFieldName": "Обновлено", "dateTimeFormat": "dd.MM.yyyy HH:mm", "csvLinks": [ "http://redmine.my-company.local/projects/test-project/issues.csv?query_id=2011", // закрытые за сутки "http://redmine.my-company.local/projects/test-project/issues.csv?query_id=2012" // обновленые за сутки ], "csvFiles": [ "/tmp/file1.csv", "/tmp/file2.csv" ], } `; var args = parseArgs({ options: { config: { type: 'string', }, test: { type: 'boolean', }, yes: { type: 'boolean', }, }, }); if (!args?.values?.config) { console.log(helpMsg); process.exit(0); } function readConfig(fileName) { const rawData = readFileSync(fileName, { encoding: 'utf8' }); return parse(rawData); } var config = readConfig(args.values.config); console.log(`Конфигурационный файл:\n${JSON.stringify(config, null, ' ')}\n`); // DEBUG const UNLIMIT = 999999; async function getIssuesByQuery(selector) { const resp = await fetch(`${config.eventEmitterUrl}/issues/find`, { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ selector: selector, limit: UNLIMIT, }), }); return await resp.json(); } async function getIssuesByList(issueIds) { return await getIssuesByQuery({ id: { $in: issueIds, }, }); } // eslint-disable-next-line @typescript-eslint/no-unused-vars async function getCsvFromRedmine(url, token) { const resp = await fetch(url, { headers: { 'X-Redmine-API-Key': token, }, }); const rawData = await resp.text(); return csvParse(rawData, { delimiter: ';', quote: '"', columns: true, skip_empty_lines: true, cast: (value, context) => { if (context.column == '#') { return Number(value); } return value; }, }); } async function getCsvFromLocalFile(fileName) { const rawData = readFileSync(fileName, { encoding: 'utf8' }); const data = csvParse(rawData, { delimiter: ';', quote: '"', columns: true, skip_empty_lines: true, cast: (value, context) => { if (context.column == '#') { return Number(value); } return value; }, }); return data; } async function loadAllCsv() { const res = {}; const csvLinks = config?.csvLinks || []; const csvFiles = config?.csvFiles || []; const token = config?.redmineToken || ''; function appendIssuesToRes(issues) { issues.forEach((issue) => { const issueId = Object.values(issue)[0]; issue.id = Number(issueId); const updatedAt = DateTime.fromFormat( issue[config.updatedAtFieldName], config.dateTimeFormat, ); if (!res[issueId]) { res[issueId] = issue; } else { const existsIssue = res[issueId]; const existsUpdatedAt = DateTime.fromFormat( existsIssue[config.updatedAtFieldName], config.dateTimeFormat, ); if (updatedAt.diff(existsUpdatedAt).as('seconds') > 0) { res[issueId] = issue; } } }); } for (let i = 0; i < csvLinks.length; i++) { const link = csvLinks[i]; const issues = await getCsvFromRedmine(link, token); appendIssuesToRes(issues); } for (let i = 0; i < csvFiles.length; i++) { const file = csvFiles[i]; const issues = await getCsvFromLocalFile(file); appendIssuesToRes(issues); } return Object.values(res); } async function loadFromEventEmitter(issueIds) { return await getIssuesByList(issueIds); } async function getIssueIdsForUpdate(newIssues) { if (config.forceUpdate) { return newIssues.map((i) => i.id); } const existsIssue = await loadFromEventEmitter(newIssues.map((i) => i.id)); const existsIssueMap = existsIssue.reduce((acc, issue) => { acc[issue.id] = issue; return acc; }, {}); const newIds = newIssues .filter((newIssue) => { const existsIssue = existsIssueMap[newIssue.id]; if (!existsIssue) return true; const newUpdatedAt = DateTime.fromFormat( newIssue[config.updatedAtFieldName], config.dateTimeFormat, ); const existsUpdatedTimestamp = DateTime.fromMillis( existsIssue.timestamp__, ); if (newUpdatedAt.diff(existsUpdatedTimestamp).as('seconds') > 0) { return true; } return false; }) .map((i) => i.id); return newIds; } const issues = await loadAllCsv(); const idsForUpdate = await getIssueIdsForUpdate(issues); console.log(`Из csv получено задач - ${issues.length} шт.`); console.log(`Нужно обновить задач - ${idsForUpdate.length} шт.`); console.log(`Задачи для обновления - ` + JSON.stringify(idsForUpdate)); async function continueQuestion() { if (args.values.yes) { return true; } const readline = createInterface({ input: process.stdin, output: process.stdout, }); const answer = await readline.question(`\nПродолжить? (y/n)\n> `); const res = answer == 'y'; readline.close(); console.log(res ? `\nПродолжаем...` : '\nНу и мне тогда это не надо'); return res; } if (!args.values.test) { const answer = await continueQuestion(); if (answer) { console.log(`Отправка задачи на синхронизацию...`); await fetch( `${config.eventEmitterUrl}/redmine-event-emitter/append-issues`, { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify(idsForUpdate), }, ); console.log(`Синхронизация запущена`); } }