mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
Шард 2/4 прогона 35565222849 снят по timeout-minutes: реестр вырос до 810 мутантов (~203 на шард), длительность за 11 дней 42 → 61 мин при потолке 60. Отчёт назвал его «ok»: лог без строк FAIL считался зелёным, а обрыв по таймауту строк FAIL не содержит. Агрегатор проверял identity, но не завершённость — evidence шага `if: always()` было на месте. - mutation-gate.yml: matrix из шести шардов, `--shard=i/6`, `--shards=6`; шаг прогона получил id, его `outcome` пишется в evidence. - mutation-gate-report.mjs: шард `ok` только с итоговой строкой `поймано N из M`, N = M, без FAIL и с исходом шага `success`; лог без итога или исход `cancelled`/`skipped` — `interrupted`, отказ; агрегатор отвергает прерванный шард как неполный. `outcome` в evidence необязателен ради старых артефактов, но, если назван, обязан быть из известного набора. - тесты: обрыв → interrupted, исходы шага, evidence с outcome; делитель шардов один во всех местах workflow; фикстуры зелёных логов получили итог. - мутанты: mutation-report-truncated-log-is-ok, mutation-evidence-ignores-cancelled-step. - docs/TESTING.md: шесть шардов, итоговая строка. Потолок 60 минут остаётся стражем от зависшего Chromium. Ledger в ночном прогоне не включён: ночь гоняет всё. Issue: #604 User-Visible: no
411 lines
21 KiB
JavaScript
Executable File
411 lines
21 KiB
JavaScript
Executable File
#!/usr/bin/env node
|
||
/**
|
||
* Отчёт об отказе полного мутационного прогона по расписанию (#472).
|
||
*
|
||
* Что случилось. Еженедельный прогон `mutation-gate.yml` дважды подряд не дал
|
||
* зелёного результата (24.08 отменён, 31.08 красный во всех шардах), и никто
|
||
* этого не открыл: у отказа не было адресата. Пять дней спустя ручной прогон
|
||
* перед стабильной v1.72.0 остановил релиз теми же сбежавшими мутантами
|
||
* (#465–#467). Механизм есть — его выход никто не читает.
|
||
*
|
||
* Этот модуль — чистая функция «логи шардов → отчёт» и тонкий CLI. Обвязка
|
||
* (`gh issue`, `curl` в Telegram) остаётся в workflow, потому что у shell там
|
||
* нет тестов, а у разбора логов — есть (урок #454).
|
||
*
|
||
* Две формы `FAIL` в логе раннера различаются явно:
|
||
*
|
||
* FAIL <mutant-id>: тест остался зелёным на сломанном коде → escaped
|
||
* FAIL чистый прогон: <guard> красный без мутанта → redGuards
|
||
* FAIL <mutant-id>: ошибка подготовки до заявленного теста → unverifiable
|
||
*
|
||
* Наивный парсер «id — это слово после FAIL» сделал бы из второй формы
|
||
* мутанта по имени «чистый», которого в реестре нет, и команда `--id=чистый`
|
||
* в письме не сработала бы. Поэтому id обязан существовать в реестре; всё
|
||
* остальное уходит в `unparsed` с текстом как есть — потерять строку нельзя,
|
||
* но и выдумывать из неё сущность тоже.
|
||
*/
|
||
import {
|
||
existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync,
|
||
} from 'node:fs';
|
||
import { dirname, join } from 'node:path';
|
||
import { isMainModule } from './spawn-portable.mjs';
|
||
|
||
export const REPORT_TITLE_MARKER = '[mutation-gate] отказ прогона по расписанию';
|
||
export const MUTATION_EVIDENCE_SCHEMA = 'houseplan-mutation-shard-evidence/v1';
|
||
|
||
const ESCAPED_LINE = /^FAIL (\S+): тест остался зелёным на сломанном коде\s*$/;
|
||
const RED_GUARD_LINE = /^FAIL чистый прогон: (.+?) красный без мутанта\s*$/;
|
||
const UNVERIFIABLE_LINE = /^FAIL (\S+): (неприменимый мутант|ошибка подготовки до заявленного теста|прерывание инфраструктуры)\s*$/;
|
||
const CLEAN_UNVERIFIABLE_LINE = /^FAIL чистая (подготовка|инфраструктура): (.+?)\s*$/;
|
||
const ANY_FAIL_LINE = /^FAIL /;
|
||
// Итоговая строка раннера (`scripts/mutation-gate.mjs`, конец main). Лог без
|
||
// неё — обрыв: шард снят по timeout-minutes или отменён, и «FAIL не встретился»
|
||
// значит лишь «до FAIL не дошли». 21.09 (#604) шард 2/4 умер на 60-й минуте
|
||
// после ≥155 зелёных строк, и отчёт назвал его «ok».
|
||
const SUMMARY_LINE = /^поймано (\d+) из (\d+)\s*$/;
|
||
// Исходы шага GitHub Actions (`steps.<id>.outcome`), которые шард пишет в
|
||
// evidence (#604). `cancelled` — timeout job или отмена прогона; такой шард
|
||
// не завершён независимо от того, что успел попасть в лог.
|
||
export const SHARD_OUTCOMES = Object.freeze(['success', 'failure', 'cancelled', 'skipped']);
|
||
const INTERRUPTED_OUTCOMES = new Set(['cancelled', 'skipped']);
|
||
|
||
const FULL_SHA = /^[0-9a-f]{40}$/;
|
||
const positiveInteger = (value) => Number.isInteger(Number(value)) && Number(value) > 0;
|
||
|
||
/** Machine-readable identity written beside every nightly shard log (#549). */
|
||
export function mutationShardEvidence(input) {
|
||
const evidence = {
|
||
schema: MUTATION_EVIDENCE_SCHEMA,
|
||
materialSha: String(input.materialSha || ''),
|
||
materialTree: String(input.materialTree || ''),
|
||
workflowSha: String(input.workflowSha || ''),
|
||
runId: Number(input.runId),
|
||
runAttempt: Number(input.runAttempt),
|
||
shard: Number(input.shard),
|
||
shardCount: Number(input.shardCount),
|
||
};
|
||
// Исход шага прогона — необязателен ради артефактов, записанных до #604;
|
||
// если назван, обязан быть одним из известных: опечатка в workflow не должна
|
||
// превращаться в «неизвестно, значит ок».
|
||
const outcome = input.outcome == null || input.outcome === '' ? undefined : String(input.outcome);
|
||
if (outcome !== undefined) {
|
||
if (!SHARD_OUTCOMES.includes(outcome)) throw new Error(`unknown shard outcome: ${outcome}`);
|
||
evidence.outcome = outcome;
|
||
}
|
||
if (!FULL_SHA.test(evidence.materialSha) || !FULL_SHA.test(evidence.materialTree)
|
||
|| !FULL_SHA.test(evidence.workflowSha) || !positiveInteger(evidence.runId)
|
||
|| !positiveInteger(evidence.runAttempt) || !positiveInteger(evidence.shard)
|
||
|| !positiveInteger(evidence.shardCount) || evidence.shard > evidence.shardCount) {
|
||
throw new Error('invalid mutation shard evidence identity');
|
||
}
|
||
return evidence;
|
||
}
|
||
|
||
/**
|
||
* Select the newest artifact attempt for each shard, then prove that the whole
|
||
* set belongs to one immutable material and workflow run. Older artifacts are
|
||
* deliberately ignored so a full rerun may pin a fresh material, while a
|
||
* partial rerun can reuse successful shards from its earlier attempt.
|
||
*/
|
||
export function validateMutationShardEvidence(rows, expected) {
|
||
const errors = [];
|
||
const byShard = new Map();
|
||
for (const row of rows || []) {
|
||
if (row?.error) { errors.push(`${row.file || 'evidence'}: ${row.error}`); continue; }
|
||
const evidence = row?.evidence;
|
||
try {
|
||
mutationShardEvidence(evidence || {});
|
||
} catch {
|
||
errors.push(`${row?.file || 'evidence'}: invalid evidence identity`);
|
||
continue;
|
||
}
|
||
const shard = Number(evidence.shard);
|
||
const current = byShard.get(shard);
|
||
if (!current || Number(evidence.runAttempt) > Number(current.evidence.runAttempt)) {
|
||
byShard.set(shard, row);
|
||
} else if (Number(evidence.runAttempt) === Number(current.evidence.runAttempt)) {
|
||
errors.push(`shard ${shard}: duplicate evidence for attempt ${evidence.runAttempt}`);
|
||
}
|
||
}
|
||
|
||
const selected = [];
|
||
const shardCount = Number(expected.shardCount);
|
||
for (let shard = 1; shard <= shardCount; shard++) {
|
||
const row = byShard.get(shard);
|
||
if (!row) { errors.push(`shard ${shard}: evidence is missing`); continue; }
|
||
const evidence = row.evidence;
|
||
selected.push(row);
|
||
if (evidence.schema !== MUTATION_EVIDENCE_SCHEMA) errors.push(`shard ${shard}: wrong schema`);
|
||
if (Number(evidence.shardCount) !== shardCount) errors.push(`shard ${shard}: wrong shard count`);
|
||
if (evidence.materialSha !== expected.materialSha) errors.push(`shard ${shard}: foreign material SHA`);
|
||
if (evidence.materialTree !== expected.materialTree) errors.push(`shard ${shard}: foreign material tree`);
|
||
if (evidence.workflowSha !== expected.workflowSha) errors.push(`shard ${shard}: foreign workflow SHA`);
|
||
if (Number(evidence.runId) !== Number(expected.runId)) errors.push(`shard ${shard}: foreign run id`);
|
||
if (!positiveInteger(evidence.runAttempt)
|
||
|| Number(evidence.runAttempt) > Number(expected.runAttempt)) {
|
||
errors.push(`shard ${shard}: impossible run attempt`);
|
||
}
|
||
// Полнота — это не только «артефакт пришёл», но и «шард дошёл до конца»
|
||
// (#604): снятый по таймауту шаг оставляет evidence через `if: always()`,
|
||
// и без этой проверки агрегатор приписывал material результат, которого нет.
|
||
if (INTERRUPTED_OUTCOMES.has(evidence.outcome)) {
|
||
errors.push(`shard ${shard}: run was interrupted (step outcome ${evidence.outcome})`);
|
||
}
|
||
}
|
||
return { ok: errors.length === 0, errors, selected };
|
||
}
|
||
|
||
function evidenceFiles(root) {
|
||
if (!existsSync(root)) return [];
|
||
const out = [];
|
||
const walk = (dir) => {
|
||
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
||
const path = join(dir, entry.name);
|
||
if (entry.isDirectory()) walk(path);
|
||
else if (entry.name === 'evidence.json') out.push(path);
|
||
}
|
||
};
|
||
walk(root);
|
||
return out.sort();
|
||
}
|
||
|
||
export function loadMutationShardArtifacts(root, expected) {
|
||
const rows = evidenceFiles(root).map((file) => {
|
||
try { return { file, evidence: JSON.parse(readFileSync(file, 'utf8')) }; }
|
||
catch (error) { return { file, error: `invalid JSON: ${error.message}` }; }
|
||
});
|
||
const validation = validateMutationShardEvidence(rows, expected);
|
||
const selectedByShard = new Map(validation.selected.map((row) => [Number(row.evidence.shard), row]));
|
||
const logs = [];
|
||
for (let shard = 1; shard <= Number(expected.shardCount); shard++) {
|
||
const row = selectedByShard.get(shard);
|
||
const path = row ? join(dirname(row.file), `mutation-shard-${shard}.log`) : '';
|
||
logs.push({
|
||
shard,
|
||
text: path && existsSync(path) ? readFileSync(path, 'utf8') : null,
|
||
outcome: row?.evidence?.outcome,
|
||
});
|
||
if (row && (!path || !existsSync(path))) validation.errors.push(`shard ${shard}: log is missing`);
|
||
}
|
||
validation.ok = validation.errors.length === 0;
|
||
return { ...validation, logs };
|
||
}
|
||
|
||
/**
|
||
* Разобрать логи шардов.
|
||
*
|
||
* @param {Array<{ shard: number, text: string | null, outcome?: string }>} logs —
|
||
* `text: null` означает, что артефакт шарда не пришёл. Это тоже отказ: «лога
|
||
* нет» не значит «сбежавших нет», это значит «мы не знаем». `outcome` —
|
||
* исход шага прогона из evidence, если записан (#604).
|
||
* @param {Set<string>|string[]} knownIds — id реестра.
|
||
*
|
||
* Статусы шарда: `ok` — лог дошёл до итоговой строки `поймано N из M`, N = M,
|
||
* ни одной строки FAIL, исход шага не назван или `success`; `failed` — есть
|
||
* FAIL либо итог не сходится либо шаг красный без FAIL; `interrupted` — лог
|
||
* обрывается до итоговой строки или исход шага `cancelled`/`skipped` (таймаут,
|
||
* отмена) — тоже отказ, потому что до сбежавших могли не дойти; `missing` —
|
||
* артефакта нет.
|
||
*/
|
||
export function parseShardLogs(logs, knownIds) {
|
||
const known = new Set(knownIds);
|
||
const escaped = new Set();
|
||
const redGuards = new Set();
|
||
const unverifiable = [];
|
||
const unparsed = [];
|
||
const shards = [];
|
||
for (const { shard, text, outcome } of logs) {
|
||
if (text == null) { shards.push({ shard, status: 'missing' }); continue; }
|
||
let failed = false;
|
||
let summary = null;
|
||
for (const raw of String(text).split('\n')) {
|
||
const line = raw.replace(/\r$/, '');
|
||
const asSummary = SUMMARY_LINE.exec(line);
|
||
if (asSummary) { summary = { caught: Number(asSummary[1]), total: Number(asSummary[2]) }; continue; }
|
||
if (!ANY_FAIL_LINE.test(line)) continue;
|
||
failed = true;
|
||
const asEscaped = ESCAPED_LINE.exec(line);
|
||
if (asEscaped && known.has(asEscaped[1])) { escaped.add(asEscaped[1]); continue; }
|
||
const asRed = RED_GUARD_LINE.exec(line);
|
||
if (asRed) { redGuards.add(asRed[1]); continue; }
|
||
const asUnverifiable = UNVERIFIABLE_LINE.exec(line);
|
||
if (asUnverifiable && known.has(asUnverifiable[1])) {
|
||
unverifiable.push({ shard, id: asUnverifiable[1], reason: asUnverifiable[2] });
|
||
continue;
|
||
}
|
||
const asCleanUnverifiable = CLEAN_UNVERIFIABLE_LINE.exec(line);
|
||
if (asCleanUnverifiable) {
|
||
unverifiable.push({ shard, id: '', reason: `чистая ${asCleanUnverifiable[1]}: ${asCleanUnverifiable[2]}` });
|
||
continue;
|
||
}
|
||
unparsed.push({ shard, line });
|
||
}
|
||
let status;
|
||
if (failed) status = 'failed';
|
||
else if (INTERRUPTED_OUTCOMES.has(outcome) || !summary) status = 'interrupted';
|
||
else if (summary.caught !== summary.total || (outcome !== undefined && outcome !== 'success')) status = 'failed';
|
||
else status = 'ok';
|
||
shards.push({ shard, status });
|
||
}
|
||
return {
|
||
escaped: [...escaped].sort(),
|
||
redGuards: [...redGuards].sort(),
|
||
unverifiable,
|
||
unparsed,
|
||
shards: shards.sort((a, b) => a.shard - b.shard),
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Собрать заголовок и тело issue.
|
||
*
|
||
* @param {object} input
|
||
* @param {Array<{ shard: number, text: string | null }>} input.logs
|
||
* @param {Map<string, string>|Record<string, string>} input.guards — id → guard
|
||
* @param {string} input.runUrl
|
||
* @param {string} input.ref
|
||
* @param {string} input.sha
|
||
* @param {string} input.date — ISO
|
||
*/
|
||
export function mutationGateReport(input) {
|
||
const guards = input.guards instanceof Map ? input.guards : new Map(Object.entries(input.guards || {}));
|
||
const parsed = parseShardLogs(input.logs || [], [...guards.keys()]);
|
||
const evidenceErrors = [...(input.evidenceErrors || [])];
|
||
const failed = evidenceErrors.length > 0 || parsed.shards.some((s) => s.status !== 'ok')
|
||
|| parsed.escaped.length > 0 || parsed.redGuards.length > 0
|
||
|| parsed.unverifiable.length > 0 || parsed.unparsed.length > 0;
|
||
const lines = [];
|
||
lines.push(`Полный мутационный прогон по расписанию не прошёл: ${input.date}, \`${input.ref}\` @ \`${String(input.sha || '').slice(0, 12)}\`.`);
|
||
lines.push(`Прогон: ${input.runUrl}`);
|
||
lines.push('');
|
||
lines.push('| шард | результат |');
|
||
lines.push('|---|---|');
|
||
for (const s of parsed.shards) {
|
||
const label = s.status === 'ok' ? 'ok'
|
||
: s.status === 'failed' ? '**красный**'
|
||
: s.status === 'interrupted' ? '**прерван — лог без итоговой строки (таймаут или отмена)**'
|
||
: '**артефакт не пришёл**';
|
||
lines.push(`| ${s.shard} | ${label} |`);
|
||
}
|
||
if (evidenceErrors.length) {
|
||
lines.push('');
|
||
lines.push('## Материал шардов не доказан');
|
||
lines.push('');
|
||
lines.push('Агрегатор отверг смешанные или неполные evidence; общий результат этому SHA не приписывается.');
|
||
lines.push('');
|
||
for (const error of evidenceErrors) lines.push(`- ${error}`);
|
||
}
|
||
if (parsed.escaped.length) {
|
||
lines.push('');
|
||
lines.push(`## Сбежавшие мутанты (${parsed.escaped.length})`);
|
||
lines.push('');
|
||
lines.push('Тест остался зелёным на сломанном коде — свидетель разучился краснеть. Воспроизведение:');
|
||
lines.push('');
|
||
for (const id of parsed.escaped) {
|
||
lines.push(`- \`${id}\` — \`node scripts/mutation-gate.mjs --id=${id}\``);
|
||
const guard = guards.get(id);
|
||
if (guard) lines.push(` guard: \`${guard}\``);
|
||
}
|
||
}
|
||
if (parsed.redGuards.length) {
|
||
lines.push('');
|
||
lines.push(`## Гарды, красные без мутанта (${parsed.redGuards.length})`);
|
||
lines.push('');
|
||
lines.push('Это не сбежавший мутант: тест падает и на исправном коде, доказать им ничего нельзя. Команда как есть:');
|
||
lines.push('');
|
||
for (const guard of parsed.redGuards) lines.push(`- \`${guard}\``);
|
||
}
|
||
if (parsed.unverifiable.length) {
|
||
lines.push('');
|
||
lines.push(`## Свидетели без доказательства (${parsed.unverifiable.length})`);
|
||
lines.push('');
|
||
lines.push('Заявленный тест не дал вердикт: такой исход не записывается в ledger и не переиспользуется.');
|
||
lines.push('');
|
||
for (const item of parsed.unverifiable) {
|
||
const target = item.id ? `\`${item.id}\`` : `шард ${item.shard}`;
|
||
lines.push(`- ${target} — ${item.reason}`);
|
||
}
|
||
}
|
||
if (parsed.unparsed.length) {
|
||
lines.push('');
|
||
lines.push(`## Неразобранные строки FAIL (${parsed.unparsed.length})`);
|
||
lines.push('');
|
||
for (const { shard, line } of parsed.unparsed) lines.push(`- шард ${shard}: \`${line}\``);
|
||
}
|
||
const missing = parsed.shards.filter((s) => s.status === 'missing');
|
||
if (missing.length) {
|
||
lines.push('');
|
||
lines.push(`Артефакты шардов ${missing.map((s) => s.shard).join(', ')} не пришли — это отказ, а не отсутствие сбежавших.`);
|
||
}
|
||
const interrupted = parsed.shards.filter((s) => s.status === 'interrupted');
|
||
if (interrupted.length) {
|
||
lines.push('');
|
||
lines.push(`Шарды ${interrupted.map((s) => s.shard).join(', ')} прерваны до итоговой строки \`поймано N из M\` — `
|
||
+ 'timeout-minutes job или отмена прогона (#604). Сбежавших там не разобрано: до них могли не дойти. '
|
||
+ 'Если это таймаут — реестр вырос, шардов не хватает.');
|
||
}
|
||
return {
|
||
title: `${REPORT_TITLE_MARKER}: ${input.date}`,
|
||
body: `${lines.join('\n')}\n`,
|
||
failed,
|
||
...parsed,
|
||
};
|
||
}
|
||
|
||
/** Короткий текст для Telegram: заголовок, сбежавшие, ссылка. */
|
||
export function telegramSummary(report, issueUrl) {
|
||
const head = `⛔ houseplan-card: ${REPORT_TITLE_MARKER}`;
|
||
const escaped = report.escaped.length
|
||
? `сбежали: ${report.escaped.slice(0, 8).join(', ')}${report.escaped.length > 8 ? ` +${report.escaped.length - 8}` : ''}`
|
||
: 'сбежавших не разобрано';
|
||
const shards = report.shards.filter((s) => s.status !== 'ok').map((s) => `${s.shard}:${s.status}`).join(' ');
|
||
return `${head}\n${escaped}\nшарды: ${shards || '—'}\n${issueUrl}`;
|
||
}
|
||
|
||
// #496: pathToFileURL, не `file://${argv}` — на Windows последнее давало
|
||
// `file:///C:/C:/...`, CLI считал себя импортированным и молчал.
|
||
const invokedDirectly = isMainModule(import.meta.url);
|
||
if (invokedDirectly) {
|
||
const argv = process.argv.slice(2);
|
||
const value = (name, fallback = '') => {
|
||
const found = argv.find((item) => item.startsWith(`--${name}=`));
|
||
return found ? found.slice(name.length + 3) : fallback;
|
||
};
|
||
const writeEvidence = value('write-evidence');
|
||
const shardCount = Number(value('shards', '6'));
|
||
if (writeEvidence) {
|
||
const evidence = mutationShardEvidence({
|
||
materialSha: value('sha'), materialTree: value('tree'), workflowSha: value('workflow-sha'),
|
||
runId: value('run-id'), runAttempt: value('run-attempt'),
|
||
shard: value('shard'), shardCount, outcome: value('outcome'),
|
||
});
|
||
mkdirSync(dirname(writeEvidence), { recursive: true });
|
||
writeFileSync(writeEvidence, `${JSON.stringify(evidence, null, 2)}\n`, 'utf8');
|
||
console.log(`evidence=${writeEvidence}`);
|
||
} else {
|
||
const dir = value('logs', 'artifacts/mutation-logs');
|
||
const expected = {
|
||
materialSha: value('sha'), materialTree: value('tree'), workflowSha: value('workflow-sha'),
|
||
runId: Number(value('run-id')), runAttempt: Number(value('run-attempt')), shardCount,
|
||
};
|
||
const requireEvidence = argv.includes('--require-evidence') || argv.includes('--verify-only');
|
||
let logs = [];
|
||
let evidenceErrors = [];
|
||
if (requireEvidence) {
|
||
const loaded = loadMutationShardArtifacts(dir, expected);
|
||
logs = loaded.logs;
|
||
evidenceErrors = loaded.errors;
|
||
for (const error of evidenceErrors) console.error(`evidence: ${error}`);
|
||
if (argv.includes('--verify-only')) {
|
||
console.log(`verified=${loaded.ok}`);
|
||
if (!loaded.ok) process.exitCode = 1;
|
||
}
|
||
} else {
|
||
for (let shard = 1; shard <= shardCount; shard++) {
|
||
const path = `${dir}/mutation-shard-${shard}/mutation-shard-${shard}.log`;
|
||
logs.push({ shard, text: existsSync(path) ? readFileSync(path, 'utf8') : null });
|
||
}
|
||
}
|
||
if (!argv.includes('--verify-only')) {
|
||
const { MUTANTS } = await import('./mutation-gate.mjs');
|
||
const guards = new Map(MUTANTS.map((m) => [m.id, m.guard]));
|
||
const report = mutationGateReport({
|
||
logs, guards, evidenceErrors,
|
||
runUrl: value('run-url'), ref: value('ref', 'dev'), sha: value('sha'),
|
||
date: value('date', new Date().toISOString().slice(0, 10)),
|
||
});
|
||
const bodyPath = value('body-out', 'artifacts/mutation-report.md');
|
||
mkdirSync(dirname(bodyPath), { recursive: true });
|
||
writeFileSync(bodyPath, report.body, 'utf8');
|
||
const summaryPath = value('telegram-out', 'artifacts/mutation-telegram.txt');
|
||
mkdirSync(dirname(summaryPath), { recursive: true });
|
||
writeFileSync(summaryPath, telegramSummary(report, value('issue-url', '(issue)')), 'utf8');
|
||
console.log(`title=${report.title}`);
|
||
console.log(`marker=${REPORT_TITLE_MARKER}`);
|
||
console.log(`body=${bodyPath}`);
|
||
console.log(`escaped=${report.escaped.join(',')}`);
|
||
console.log(`failed=${report.failed}`);
|
||
}
|
||
}
|
||
}
|