From 9583e1a65c5f11c4aa66e2e8656cde9e70d9b5a8 Mon Sep 17 00:00:00 2001 From: silverwind Date: Thu, 26 Mar 2026 10:48:09 +0100 Subject: [PATCH] Linkify URLs in Actions workflow logs (#36986) Detect URLs in Actions log output and render them as clickable links, similar to how GitHub Actions handles this. Pre-existing links from ansi_up's OSC 8 parsing are also kept intact. --------- Signed-off-by: silverwind Co-authored-by: Claude (claude-opus-4-6) Co-authored-by: wxiaoguang --- web_src/css/themes/theme-gitea-dark.css | 1 + web_src/css/themes/theme-gitea-light.css | 1 + web_src/js/components/ActionRunJobView.vue | 5 ++++ web_src/js/render/ansi.test.ts | 5 ++++ web_src/js/render/ansi.ts | 29 ++++++++++++---------- web_src/js/utils/url.test.ts | 29 +++++++++++++++++++++- web_src/js/utils/url.ts | 28 +++++++++++++++++++++ 7 files changed, 84 insertions(+), 14 deletions(-) diff --git a/web_src/css/themes/theme-gitea-dark.css b/web_src/css/themes/theme-gitea-dark.css index f347589509..c62c20f93a 100644 --- a/web_src/css/themes/theme-gitea-dark.css +++ b/web_src/css/themes/theme-gitea-dark.css @@ -74,6 +74,7 @@ gitea-theme-meta-info { --color-console-active-bg: #2e353b; --color-console-menu-bg: #262b31; --color-console-menu-border: #414b55; + --color-console-link: #8f9ba8; /* named colors */ --color-red: #cc4848; --color-orange: #cc580c; diff --git a/web_src/css/themes/theme-gitea-light.css b/web_src/css/themes/theme-gitea-light.css index dc916f002d..5f437c5a6c 100644 --- a/web_src/css/themes/theme-gitea-light.css +++ b/web_src/css/themes/theme-gitea-light.css @@ -74,6 +74,7 @@ gitea-theme-meta-info { --color-console-active-bg: #d0d7de; --color-console-menu-bg: #f8f9fb; --color-console-menu-border: #d0d7de; + --color-console-link: #5c656d; /* named colors */ --color-red: #db2828; --color-orange: #f2711c; diff --git a/web_src/js/components/ActionRunJobView.vue b/web_src/js/components/ActionRunJobView.vue index 9d8ee0dbde..747889d04c 100644 --- a/web_src/js/components/ActionRunJobView.vue +++ b/web_src/js/components/ActionRunJobView.vue @@ -641,6 +641,11 @@ async function hashChangeListener() { overflow-wrap: anywhere; } +.job-step-logs .log-msg a { + color: var(--color-console-link) !important; + text-decoration: underline; +} + .job-step-logs .job-log-line .log-cmd-command { color: var(--color-ansi-blue); } diff --git a/web_src/js/render/ansi.test.ts b/web_src/js/render/ansi.test.ts index 21b7523994..2d9b8ede00 100644 --- a/web_src/js/render/ansi.test.ts +++ b/web_src/js/render/ansi.test.ts @@ -17,4 +17,9 @@ test('renderAnsi', () => { // treat "\033[0K" and "\033[0J" (Erase display/line) as "\r", then it will be covered to "\n" finally. expect(renderAnsi('a\x1b[Kb\x1b[2Jc')).toEqual('a\nb\nc'); expect(renderAnsi('\x1b[48;5;88ma\x1b[38;208;48;5;159mb\x1b[m')).toEqual(`ab`); + + // URLs in ANSI output become clickable links + const link = (url: string) => `${url}`; + expect(renderAnsi('Downloading https://github.com/actions/upload-artifact/releases')).toEqual(`Downloading ${link('https://github.com/actions/upload-artifact/releases')}`); + expect(renderAnsi('\x1b[32mhttps://proxy.golang.org/cached-only\x1b[0m')).toEqual(`${link('https://proxy.golang.org/cached-only')}`); }); diff --git a/web_src/js/render/ansi.ts b/web_src/js/render/ansi.ts index f5429ef6ad..4625e54233 100644 --- a/web_src/js/render/ansi.ts +++ b/web_src/js/render/ansi.ts @@ -1,4 +1,5 @@ import {AnsiUp} from 'ansi_up'; +import {linkifyURLs} from '../utils/url.ts'; const replacements: Array<[RegExp, string]> = [ [/\x1b\[\d+[A-H]/g, ''], // Move cursor, treat them as no-op @@ -25,21 +26,23 @@ export function renderAnsi(line: string): string { } } + let result: string; if (!line.includes('\r')) { - return ansi_up.ansi_to_html(line); - } - - // handle "\rReading...1%\rReading...5%\rReading...100%", - // convert it into a multiple-line string: "Reading...1%\nReading...5%\nReading...100%" - const lines: Array = []; - for (const part of line.split('\r')) { - if (part === '') continue; - const partHtml = ansi_up.ansi_to_html(part); - if (partHtml !== '') { - lines.push(partHtml); + result = ansi_up.ansi_to_html(line); + } else { + // handle "\rReading...1%\rReading...5%\rReading...100%", + // convert it into a multiple-line string: "Reading...1%\nReading...5%\nReading...100%" + const lines: Array = []; + for (const part of line.split('\r')) { + if (part === '') continue; + const partHtml = ansi_up.ansi_to_html(part); + if (partHtml !== '') { + lines.push(partHtml); + } } + // the log message element is with "white-space: break-spaces;", so use "\n" to break lines + result = lines.join('\n'); } - // the log message element is with "white-space: break-spaces;", so use "\n" to break lines - return lines.join('\n'); + return linkifyURLs(result); } diff --git a/web_src/js/utils/url.test.ts b/web_src/js/utils/url.test.ts index c39dd15732..3a4323e88f 100644 --- a/web_src/js/utils/url.test.ts +++ b/web_src/js/utils/url.test.ts @@ -1,10 +1,37 @@ -import {pathEscapeSegments, toOriginUrl} from './url.ts'; +import {linkifyURLs, pathEscapeSegments, toOriginUrl} from './url.ts'; test('pathEscapeSegments', () => { expect(pathEscapeSegments('a/b/c')).toEqual('a/b/c'); expect(pathEscapeSegments('a/b/ c')).toEqual('a/b/%20c'); }); +test('linkifyURLs', () => { + const link = (url: string) => `${url}`; + expect(linkifyURLs('https://example.com')).toEqual(link('https://example.com')); + expect(linkifyURLs('https://dl.google.com/go/go1.23.6.linux-amd64.tar.gz')).toEqual(link('https://dl.google.com/go/go1.23.6.linux-amd64.tar.gz')); + expect(linkifyURLs('https://example.com/path?query=1&b=2#frag')).toEqual(link('https://example.com/path?query=1&b=2#frag')); + expect(linkifyURLs('visit https://example.com/repo for info')).toEqual(`visit ${link('https://example.com/repo')} for info`); + expect(linkifyURLs('See https://example.com.')).toEqual(`See ${link('https://example.com')}.`); + expect(linkifyURLs('https://example.com, and more')).toEqual(`${link('https://example.com')}, and more`); + expect(linkifyURLs('https://proxy.golang.org/cached-only')).toEqual(`${link('https://proxy.golang.org/cached-only')}`); + expect(linkifyURLs('https://registry.npmjs.org/@types/node')).toEqual(`${link('https://registry.npmjs.org/@types/node')}`); + expect(linkifyURLs('https://a.com and https://b.org')).toEqual(`${link('https://a.com')} and ${link('https://b.org')}`); + expect(linkifyURLs('no urls here')).toEqual('no urls here'); + expect(linkifyURLs('http://example.com/path')).toEqual(link('http://example.com/path')); + expect(linkifyURLs('http://localhost:3000/repo')).toEqual(link('http://localhost:3000/repo')); + expect(linkifyURLs('https://')).toEqual('https://'); + expect(linkifyURLs('Click here')).toEqual('Click here'); + expect(linkifyURLs('Click here')).toEqual('Click here'); + expect(linkifyURLs('https://example.com')).toEqual('https://example.com'); + expect(linkifyURLs('https://evil.com/')).toEqual(`${link('https://evil.com/')}`); + expect(linkifyURLs('https://evil.com/"onmouseover="alert(1)')).toEqual(`${link('https://evil.com/')}"onmouseover="alert(1)`); + expect(linkifyURLs('javascript:alert(1)')).toEqual('javascript:alert(1)'); // eslint-disable-line no-script-url + expect(linkifyURLs("https://evil.com/'onclick='alert(1)")).toEqual(`${link('https://evil.com/')}'onclick='alert(1)`); + expect(linkifyURLs('data:text/html,')).toEqual('data:text/html,'); + expect(linkifyURLs('https://evil.com/\nonclick=alert(1)')).toEqual(`${link('https://evil.com/')}\nonclick=alert(1)`); + expect(linkifyURLs('https://evil.com/"onmouseover=alert(1)')).toEqual(`${link('https://evil.com/"onmouseover=alert')}(1)`); +}); + test('toOriginUrl', () => { const oldLocation = String(window.location); for (const origin of ['https://example.com', 'https://example.com:3000']) { diff --git a/web_src/js/utils/url.ts b/web_src/js/utils/url.ts index 6bcb4c1609..469693373a 100644 --- a/web_src/js/utils/url.ts +++ b/web_src/js/utils/url.ts @@ -2,6 +2,34 @@ export function pathEscapeSegments(s: string): string { return s.split('/').map(encodeURIComponent).join('/'); } +// Match HTML tags (to skip) or URLs (to linkify) in HTML content +const urlLinkifyPattern = /(<([-\w]+)[^>]*>)|(<\/([-\w]+)[^>]*>)|(https?:\/\/[^\s<>"'`|(){}[\]]+)/gi; +const trailingPunctPattern = /[.,;:!?]+$/; + +// Convert URLs to clickable links in HTML, preserving existing HTML tags +export function linkifyURLs(html: string): string { + let inAnchor = false; + return html.replace(urlLinkifyPattern, (match, _openTagFull, openTag, _closeTagFull, closeTag, url) => { + // skip URLs inside existing tags + if (openTag === 'a') { + inAnchor = true; + return match; + } else if (closeTag === 'a') { + inAnchor = false; + return match; + } + if (inAnchor || !url) { + return match; + } + + const trailingPunct = url.match(trailingPunctPattern); + const cleanUrl = trailingPunct ? url.slice(0, -trailingPunct[0].length) : url; + const trailing = trailingPunct ? trailingPunct[0] : ''; + // safe because regexp only matches valid URLs (no quotes or angle brackets) + return `${cleanUrl}${trailing}`; // eslint-disable-line github/unescaped-html-literal + }); +} + /** Convert an absolute or relative URL to an absolute URL with the current origin. It only * processes absolute HTTP/HTTPS URLs or relative URLs like '/xxx' or '//host/xxx'. */ export function toOriginUrl(urlStr: string) {