pinkmine/tools/sync-issues.mjs

240 lines
6.7 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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