export const LOC_BLOCK_START = '' export const LOC_BLOCK_END = '' export const LOC_HANDS_OFF_COMMENT = '' // __-wrapped segments unambiguously mark test data; bare fixtures/ could be production, and this classifier bills every PR. const TEST_DIR_SEGMENT = /(?:^|\/)(?:__tests__|__fixtures__|__snapshots__|e2e|tests)(?:\/|$)/i const TEST_FILENAME = /\.(?:test|spec|e2e)\.[^/]+$/i export function isTestPath(path) { const normalized = path.replaceAll('\\', '/') return TEST_DIR_SEGMENT.test(normalized) || TEST_FILENAME.test(normalized) } export function emptyLocTotals() { return { test: { files: 0, added: 0, deleted: 0 }, nonTest: { files: 0, added: 0, deleted: 0 } } } export function sumChangedFiles(files) { const totals = emptyLocTotals() for (const file of files) { const path = file.filename if (path == null) { continue } const bucket = isTestPath(path) ? totals.test : totals.nonTest bucket.files += 1 bucket.added += Number(file.additions ?? 0) bucket.deleted += Number(file.deletions ?? 0) } return totals } const COLOR_ADDED = '#1a7f37' const COLOR_DELETED = '#cf222e' function diffCell(count) { if (count === 0) { return '0' } if (count > 0) { return `$\\color{${COLOR_ADDED}}{\\Huge{\\mathbf{+}}}$\u200b${count}` } return `$\\color{${COLOR_DELETED}}{\\Huge{\\mathbf{−}}}$\u200b${Math.abs(count)}` } function locTableRow(label, bucket) { const added = bucket.added ?? 0 const deleted = bucket.deleted ?? 0 return `| ${label} | ${bucket.files ?? 0} | ${diffCell(added)} | ${diffCell(-deleted)} | ${diffCell(added - deleted)} |` } export function formatLocTable({ test, nonTest }) { return [ '| | Files | Added | Deleted | Net |', '| :--- | ---: | ---: | ---: | ---: |', locTableRow('Test', test), locTableRow('Prod', nonTest) ].join('\n') } export function renderLocBlock(totals) { return [ LOC_BLOCK_START, LOC_HANDS_OFF_COMMENT, '', formatLocTable(totals), '', LOC_BLOCK_END ].join('\n') } function stripLocBlock(body) { const start = body.indexOf(LOC_BLOCK_START) const end = body.indexOf(LOC_BLOCK_END) if (start === -1 || end === -1 || end < start) { return body } const before = body.slice(0, start).trimEnd() const after = body.slice(end + LOC_BLOCK_END.length).trimStart() return before.length > 0 && after.length > 0 ? `${before}\n\n${after}` : before || after } const ELI5_HEADING = /^(#{1,6})[ \t]*ELI5\b.*$/im // Index of the heading that ends the ELI5 section, or -1 when absent or it runs to the end. function eli5SectionEnd(body) { const heading = ELI5_HEADING.exec(body) if (heading == null) { return -1 } const level = heading[1].length const sectionStart = heading.index + heading[0].length const nextHeading = new RegExp(`^#{1,${level}}[ \\t]`, 'm').exec(body.slice(sectionStart)) return nextHeading == null ? -1 : sectionStart + nextHeading.index } // Slack/GitHub link unfurls show the start of the body, so the table goes after ELI5 (or at the end), never first. export function mergeLocBlock(body, totals) { const block = renderLocBlock(totals) const rest = stripLocBlock(body ?? '') if (rest.trim().length === 0) { return `${block}\n` } const insertAt = eli5SectionEnd(rest) if (insertAt === -1) { return `${rest.trimEnd()}\n\n${block}\n` } return `${rest.slice(0, insertAt).trimEnd()}\n\n${block}\n\n${rest.slice(insertAt)}` }