import { styleText } from 'node:util';
import { getMachineUrl, parsePRFromURL } from '../links.js';
import CIFailureParser from './ci_failure_parser.js';
import {
parseJobFromURL,
CI_TYPES
} from './ci_type_parser.js';
import {
fold,
getHighlight,
markdownRow
} from './ci_utils.js';
const { FAILURE_TYPES_NAME } = CIFailureParser;
function uniqBy(array, key) {
const seen = new Set();
return array.filter((item) => !seen.has(item[key]) && seen.add(item[key]));
}
function formatSource(source) {
const parsed = parsePRFromURL(source);
return parsed ? `${parsed.owner}/${parsed.repo}#${parsed.prid}` : source;
}
function formatTargetBranches(pr, markdown = false) {
if (!parsePRFromURL(pr.source)) {
return '';
}
if (pr.targetBranches.length === 1 && pr.targetBranches[0] === 'main') {
return '';
}
const branches = markdown
? pr.targetBranches.map(branch =>
branch.replace(/[&<>"'\\`*_[\]{}()!|~]/g, char => `&#${char.charCodeAt(0)};`))
: pr.targetBranches;
return ` (target: ${branches.join(', ')})`;
}
export class FailureAggregator {
constructor(cli, data) {
this.cli = cli;
this.health = data[0];
this.failures = data.slice(1);
this.aggregates = null;
}
aggregate() {
const groupedByReason = Object.groupBy(this.failures, getHighlight);
const data = [];
for (const reason of Object.keys(groupedByReason).sort()) {
const failures = groupedByReason[reason];
// Uncomment this and redirect stderr away to see matched highlights
// console.log('HIGHLIGHT', reason);
// If multiple sub builds of one PR are failed by the same reason,
// we'll only take one of those builds, as that might be a genuine failure
const branchesBySource = new Map();
for (const { source, targetBranch } of failures) {
if (!branchesBySource.has(source)) {
branchesBySource.set(source, new Set());
}
branchesBySource.get(source).add(targetBranch || 'unknown');
}
const prs = uniqBy(failures, 'source')
.map(({ source, upstream }) => ({
source,
upstream,
targetBranches: [...branchesBySource.get(source)],
_id: parseJobFromURL(upstream).jobid
}))
.sort((a, b) => a._id - b._id);
const machines = uniqBy(
failures.map(f => ({ hostname: f.builtOn, url: f.url })),
'hostname');
data.push({
reason, type: failures[0].type, failures, prs, machines
});
}
const groupedByType = Object.groupBy(data, ({ type }) => type);
for (const group of Object.values(groupedByType)) {
group.sort((a, b) => b.prs.length - a.prs.length);
}
this.aggregates = groupedByType;
return groupedByType;
}
formatAsMarkdown() {
let { aggregates } = this;
if (!aggregates) {
aggregates = this.aggregates = this.aggregate();
}
let output = 'No failures to report\n';
if (this.failures.length > 0) {
const last = parseJobFromURL(this.failures[0].upstream);
const first = parseJobFromURL(
this.failures[this.failures.length - 1].upstream
);
const jobName = CI_TYPES.get(first.type).jobName;
output = 'Failures in ';
output += `[${jobName}/${first.jobid}](${first.link}) to `;
output += `[${jobName}/${last.jobid}](${last.link}) `;
output += 'that failed 2 or more PRs\n';
}
output += '(Generated with `ncu-ci ';
output += `${process.argv.slice(2).join(' ')}\`)\n\n`;
output += this.health.formatAsMarkdown() + '\n';
const todo = [];
for (const type of Object.keys(aggregates)) {
if (aggregates[type].length === 0) {
continue;
}
output += `\n### ${FAILURE_TYPES_NAME[type]}\n\n`;
for (const item of aggregates[type]) {
const { reason, type, prs, failures, machines } = item;
if (prs.length < 2) { continue; }
todo.push({ count: prs.length, reason });
output += markdownRow('Reason', `<code>${reason}</code>`);
output += markdownRow('-', ':-');
output += markdownRow('Type', type);
const source = prs.map(f =>
`[${formatSource(f.source)}](${f.upstream})${formatTargetBranches(f, true)}`);
output += markdownRow(
'Failed PR', `${source.length} (${source.join(', ')})`
);
output += markdownRow(
'Appeared', machines.map(getMachineUrl).join(', ')
);
if (prs.length > 1) {
output += markdownRow('First CI', `${prs[0].upstream}`);
}
output += markdownRow('Last CI', `${prs[prs.length - 1].upstream}`);
output += '\n';
const example = failures[0].reason;
output += fold(
`<a href="${failures[0].url}">Example</a>`,
(example.length > 1024 ? example.slice(0, 1024) + '...' : example)
);
output += '\n\n-------\n\n';
}
}
output += '### Progress\n\n';
output += todo.map(
({ count, reason }) => `- [ ] \`${reason}\` (${count})`).join('\n'
);
return output + '\n';
}
display() {
let { cli, aggregates } = this;
if (!aggregates) {
aggregates = this.aggregates = this.aggregate();
}
for (const type of Object.keys(aggregates)) {
cli.separator(type);
for (const item of aggregates[type]) {
const { reason, type, prs, failures, machines } = item;
cli.table('Reason', reason);
cli.table('Type', type);
const source = prs.map(f => `${formatSource(f.source)}${formatTargetBranches(f)}`);
cli.table('Failed PR', `${source.length} (${source.join(', ')})`);
cli.table('Appeared', machines.map(m => m.hostname).join(', '));
if (prs.length > 1) {
cli.table('First CI', `${prs[0].upstream}`);
}
cli.table('Last CI', `${prs[prs.length - 1].upstream}`);
cli.log('\n' + styleText('bold', 'Example: ') + `${failures[0].url}\n`);
const example = failures[0].reason;
cli.log(example.length > 512 ? example.slice(0, 512) + '...' : example);
cli.separator();
}
}
}
}