|
| 1 | +// Logic for triagebot GitHub Actions logs viewer |
| 2 | + |
| 3 | +const logsEl = document.getElementById("logs"); |
| 4 | +const ansi_up = new AnsiUp(); |
| 5 | +ansi_up.use_classes = true; |
| 6 | + |
| 7 | +let startingAnchorId = null; |
| 8 | + |
| 9 | +// 1. Tranform the ANSI escape codes to HTML |
| 10 | +var html = ansi_up.ansi_to_html(logs); |
| 11 | + |
| 12 | +// 2. Remove UTF-8 useless BOM and Windows Carriage Return |
| 13 | +html = html.replace(/^\uFEFF/gm, ""); |
| 14 | +html = html.replace(/\r\n/g, "\n"); |
| 15 | + |
| 16 | +// 3. Transform each log lines that doesn't start with a timestamp into a row where everything is in the second column |
| 17 | +const untsRegex = /^(?!\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d+Z)(.*)(\n)?/gm; |
| 18 | +html = html.replace(untsRegex, (match, log) => |
| 19 | + `<tr><td></td><td>${log}</td></tr>` |
| 20 | +); |
| 21 | + |
| 22 | +// 3.b Transform each log lines that start with a timestamp in a row with two columns and make the timestamp be a |
| 23 | +// self-referencial anchor. |
| 24 | +const tsRegex = /^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d+Z) (.*)(\n)?/gm; |
| 25 | +html = html.replace(tsRegex, (match, ts, log) => |
| 26 | + `<tr><td><a id="${ts}" href="#${ts}" class="timestamp" data-pseudo-content="${ts}"></a></td><td>${log}</td></tr>` |
| 27 | +); |
| 28 | + |
| 29 | +// 4. Add a anchor around every "##[error]" string |
| 30 | +let errorCounter = -1; |
| 31 | +html = html.replace(/##\[error\]/g, () => |
| 32 | + `<a id="error-${++errorCounter}" class="error-marker">##[error]</a>` |
| 33 | +); |
| 34 | + |
| 35 | +// 4.b Add a span around every "##[warning]" string |
| 36 | +html = html.replace(/##\[warning\]/g, () => |
| 37 | + `<span class="warning-marker">##[warning]</span>` |
| 38 | +); |
| 39 | + |
| 40 | +// pre-5. Polyfill the recently (2025) added `RegExp.escape` function. |
| 41 | +// Inspired by the former MDN section on escaping: |
| 42 | +// https://web.archive.org/web/20230806114646/https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_expressions#escaping |
| 43 | +const escapeRegExp = RegExp.escape || function(string) { |
| 44 | + return string.replace(/[.*+?^${}()|[\]\\/]/g, "\\$&"); |
| 45 | +}; |
| 46 | + |
| 47 | +// 5. Add anchors around some paths |
| 48 | +// Detailed examples of what the regex does is at https://regex101.com/r/vCnx9Y/2 |
| 49 | +// |
| 50 | +// But simply speaking the regex tries to find absolute (with `/checkout` prefix) and |
| 51 | +// relative paths, the path must start with one of the repository level-2 directories. |
| 52 | +// We also try to retrieve the lines and cols if given (`<path>:line:col`). |
| 53 | +// |
| 54 | +// Some examples of paths we want to find: |
| 55 | +// - src/tools/test-float-parse/src/traits.rs:173:11 |
| 56 | +// - /checkout/compiler/rustc_macros |
| 57 | +// - /checkout/src/doc/rustdoc/src/advanced-features.md |
| 58 | +// |
| 59 | +// Any other paths, in particular if prefixed by `./` or `obj/` should not taken. |
| 60 | +const pathRegex = new RegExp( |
| 61 | + "(?<boundary_start>[^a-zA-Z0-9.\\/])" |
| 62 | + + "(?<inner>(?:[\\\/]?(?:checkout[\\\/])?(?<path>(?:" |
| 63 | + + tree_roots.map(p => escapeRegExp(p)).join("|") |
| 64 | + + ")(?:[\\\/][a-zA-Z0-9_$\\\-.\\\/]+)?))" |
| 65 | + + "(?::(?<line>[0-9]+):(?<col>[0-9]+))?)(?<boundary_end>[^a-zA-Z0-9.])", |
| 66 | + "g" |
| 67 | +); |
| 68 | +html = html.replace(pathRegex, (match, boundary_start, inner, path, line, col, boundary_end) => { |
| 69 | + const pos = (line !== undefined) ? `#L${line}` : ""; |
| 70 | + return `${boundary_start}<a href="https://github.com/${owner}/${repo}/blob/${sha}/${path}${pos}" class="path-marker">${inner}</a>${boundary_end}`; |
| 71 | +}); |
| 72 | + |
| 73 | +// 6. Add the html to the table |
| 74 | +logsEl.innerHTML = html; |
| 75 | + |
| 76 | +// 7. If no anchor is given, scroll to the last error |
| 77 | +if (location.hash === "" && errorCounter >= 0) { |
| 78 | + const hasSmallViewport = window.innerWidth <= 750; |
| 79 | + document.getElementById(`error-${errorCounter}`).scrollIntoView({ |
| 80 | + behavior: 'instant', |
| 81 | + block: 'end', |
| 82 | + inline: hasSmallViewport ? 'start' : 'center' |
| 83 | + }); |
| 84 | +} |
| 85 | + |
| 86 | +// 8. If a anchor is given, highlight and scroll to the selection |
| 87 | +if (location.hash !== "") { |
| 88 | + const match = window.location.hash |
| 89 | + .match(/L?(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d+Z)(?:-L(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d+Z))?/); |
| 90 | + |
| 91 | + if (match) { |
| 92 | + const [startId, endId] = [match[1], match[2] || match[1]].map(decodeURIComponent); |
| 93 | + const startRow = logsEl.querySelector(`a[id="${startId}"]`)?.closest('tr'); |
| 94 | + |
| 95 | + if (startRow) { |
| 96 | + startingAnchorId = startId; |
| 97 | + highlightTimestampRange(startId, endId); |
| 98 | + startRow.scrollIntoView({ block: 'center' }); |
| 99 | + } |
| 100 | + } |
| 101 | +} |
| 102 | + |
| 103 | +// 9. Add a copy handler that force plain/text copy |
| 104 | +logsEl.addEventListener("copy", function(e) { |
| 105 | + var text = window.getSelection().toString(); |
| 106 | + e.clipboardData.setData('text/plain', text); |
| 107 | + e.preventDefault(); |
| 108 | +}); |
| 109 | + |
| 110 | +// 10. Add click event to handle custom hightling |
| 111 | +logsEl.addEventListener('click', (e) => { |
| 112 | + const rowEl = e.target.closest('tr'); |
| 113 | + if (!rowEl || !e.target.classList.contains("timestamp")) return; |
| 114 | + |
| 115 | + const ctrlOrMeta = e.ctrlKey || e.metaKey; |
| 116 | + const shiftKey = e.shiftKey; |
| 117 | + const rowId = getRowId(rowEl); |
| 118 | + |
| 119 | + // Prevent default link behavior |
| 120 | + e.preventDefault(); |
| 121 | + e.stopPropagation(); |
| 122 | + |
| 123 | + if (!ctrlOrMeta && !shiftKey) { |
| 124 | + // Normal click: select single row, set anchor |
| 125 | + startingAnchorId = rowId; |
| 126 | + highlightTimestampRange(startingAnchorId, startingAnchorId); |
| 127 | + } else if (shiftKey && startingAnchorId !== null) { |
| 128 | + // Shift+click: extend selection from anchor |
| 129 | + highlightTimestampRange(startingAnchorId, rowId); |
| 130 | + } else if (ctrlOrMeta) { |
| 131 | + // Ctrl/Cmd+click: new anchor (resets selection) |
| 132 | + startingAnchorId = rowId; |
| 133 | + highlightTimestampRange(startingAnchorId, startingAnchorId); |
| 134 | + } |
| 135 | + |
| 136 | + // Update our URL hash after every selection change |
| 137 | + const ids = Array.from(logsEl.querySelectorAll('tr.selected')).map(getRowId).sort(); |
| 138 | + window.location.hash = ids.length ? |
| 139 | + (ids.length === 1 ? `L${ids[0]}` : `L${ids[0]}-L${ids[ids.length-1]}`) : ''; |
| 140 | +}); |
| 141 | + |
| 142 | +// Helper function to get the ID of the given row |
| 143 | +function getRowId(rowEl) { |
| 144 | + return rowEl.querySelector('a.timestamp').id; // "2025-12-12T21:28:09.6347029Z" |
| 145 | +} |
| 146 | + |
| 147 | +// Helper function to highlight (toggle the selected class) on the given timestamp range |
| 148 | +function highlightTimestampRange(startId, endId) { |
| 149 | + const rows = Array.from(logsEl.querySelectorAll('tr')).filter(r => r.querySelector('.timestamp')); |
| 150 | + |
| 151 | + const startIndex = rows.findIndex(row => getRowId(row) === startId); |
| 152 | + const endIndex = rows.findIndex(row => getRowId(row) === endId); |
| 153 | + |
| 154 | + const start = Math.min(startIndex, endIndex); |
| 155 | + const end = Math.max(startIndex, endIndex); |
| 156 | + |
| 157 | + rows.forEach((row, index) => row.classList.toggle('selected', index >= start && index <= end)); |
| 158 | +} |
0 commit comments