240 lines
6.7 KiB
JavaScript
240 lines
6.7 KiB
JavaScript
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(`Синхронизация запущена`);
|
||
}
|
||
}
|