Color command/error logs in Actions log (#36538)

Support `[command]` and `##[error]` log command

------

Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
This commit is contained in:
silverwind
2026-02-06 16:05:32 +01:00
committed by GitHub
parent 403a73dca0
commit 915b44810d
2 changed files with 94 additions and 68 deletions

View File

@@ -1,22 +1,26 @@
import {shouldHideLine, type LogLine} from './RepoActionView.vue'; import {createLogLineMessage, parseLogLineCommand} from './RepoActionView.vue';
test('shouldHideLine', () => { test('LogLineMessage', () => {
expect(([ const cases = {
{index: 1, message: 'Starting build process', timestamp: 1000}, 'normal message': '<span class="log-msg">normal message</span>',
{index: 2, message: '::add-matcher::/home/runner/go/pkg/mod/example.com/tool/matcher.json', timestamp: 1001}, '##[group] foo': '<span class="log-msg log-cmd-group"> foo</span>',
{index: 3, message: 'Running tests...', timestamp: 1002}, '::group::foo': '<span class="log-msg log-cmd-group">foo</span>',
{index: 4, message: '##[add-matcher]/opt/hostedtoolcache/go/1.25.7/x64/matchers.json', timestamp: 1003}, '##[endgroup]': '<span class="log-msg log-cmd-endgroup"></span>',
{index: 5, message: 'Test suite started', timestamp: 1004}, '::endgroup::': '<span class="log-msg log-cmd-endgroup"></span>',
{index: 7, message: 'All tests passed', timestamp: 1006},
{index: 8, message: '::remove-matcher owner=go::', timestamp: 1007}, // parser shouldn't do any trim, keep origin output as-is
{index: 9, message: 'Build complete', timestamp: 1008}, '##[error] foo': '<span class="log-msg log-cmd-error"> foo</span>',
] as Array<LogLine>).filter((line) => !shouldHideLine(line)).map((line) => line.message)).toMatchInlineSnapshot(` '[command] foo': '<span class="log-msg log-cmd-command"> foo</span>',
[
"Starting build process", // hidden is special, it is actually skipped before creating
"Running tests...", '##[add-matcher]foo': '<span class="log-msg log-cmd-hidden">foo</span>',
"Test suite started", '::add-matcher::foo': '<span class="log-msg log-cmd-hidden">foo</span>',
"All tests passed", '::remove-matcher foo::': '<span class="log-msg log-cmd-hidden"> foo::</span>', // not correctly parsed, but we don't need it
"Build complete", };
] for (const [input, html] of Object.entries(cases)) {
`); const line = {index: 0, timestamp: 0, message: input};
const cmd = parseLogLineCommand(line);
const el = createLogLineMessage(line, cmd);
expect(el.outerHTML).toBe(html);
}
}); });

View File

@@ -13,7 +13,12 @@ import {localUserSettings} from '../modules/user-settings.ts';
// see "models/actions/status.go", if it needs to be used somewhere else, move it to a shared file like "types/actions.ts" // see "models/actions/status.go", if it needs to be used somewhere else, move it to a shared file like "types/actions.ts"
type RunStatus = 'unknown' | 'waiting' | 'running' | 'success' | 'failure' | 'cancelled' | 'skipped' | 'blocked'; type RunStatus = 'unknown' | 'waiting' | 'running' | 'success' | 'failure' | 'cancelled' | 'skipped' | 'blocked';
type StepContainerElement = HTMLElement & {_stepLogsActiveContainer?: HTMLElement} type StepContainerElement = HTMLElement & {
// To remember the last active logs container, for example: a batch of logs only starts a group but doesn't end it,
// then the following batches of logs should still use the same group (active logs container).
// maybe it can be refactored to decouple from the HTML element in the future.
_stepLogsActiveContainer?: HTMLElement;
}
export type LogLine = { export type LogLine = {
index: number; index: number;
@@ -21,19 +26,35 @@ export type LogLine = {
message: string; message: string;
}; };
// `##[group]` is from Azure Pipelines, just supported by the way. https://learn.microsoft.com/en-us/azure/devops/pipelines/scripts/logging-commands
const LogLinePrefixesGroup = ['::group::', '##[group]'];
const LogLinePrefixesEndGroup = ['::endgroup::', '##[endgroup]'];
// https://github.com/actions/toolkit/blob/master/docs/commands.md
// https://github.com/actions/runner/blob/main/docs/adrs/0276-problem-matchers.md#registration
// Although there should be no `##[add-matcher]` syntax, there are still such outputs when using act-runner
const LogLinePrefixesHidden = ['::add-matcher::', '##[add-matcher]', '::remove-matcher'];
type LogLineCommandName = 'group' | 'endgroup' | 'command' | 'error' | 'hidden';
type LogLineCommand = { type LogLineCommand = {
name: 'group' | 'endgroup', name: LogLineCommandName,
prefix: string, prefix: string,
} }
// How GitHub Actions logs work:
// * Workflow command outputs log commands like "::group::the-title", "::add-matcher::...."
// * Workflow runner parses and processes the commands to "##[group]", apply "matchers", hide secrets, etc.
// * The reported logs are the processed logs.
// HOWEVER: Gitea runner does not completely process those commands. Many works are done by the frontend at the moment.
const LogLinePrefixCommandMap: Record<string, LogLineCommandName> = {
'::group::': 'group',
'##[group]': 'group',
'::endgroup::': 'endgroup',
'##[endgroup]': 'endgroup',
'##[error]': 'error',
'[command]': 'command',
// https://github.com/actions/toolkit/blob/master/docs/commands.md
// https://github.com/actions/runner/blob/main/docs/adrs/0276-problem-matchers.md#registration
'::add-matcher::': 'hidden',
'##[add-matcher]': 'hidden',
'::remove-matcher': 'hidden', // it has arguments
};
type Job = { type Job = {
id: number; id: number;
name: string; name: string;
@@ -54,27 +75,27 @@ type JobStepState = {
manuallyCollapsed: boolean, // whether the user manually collapsed the step, used to avoid auto-expanding it again manuallyCollapsed: boolean, // whether the user manually collapsed the step, used to avoid auto-expanding it again
} }
function parseLineCommand(line: LogLine): LogLineCommand | null { export function parseLogLineCommand(line: LogLine): LogLineCommand | null {
for (const prefix of LogLinePrefixesGroup) { // TODO: in the future it can be refactored to be a general parser that can parse arguments, drop the "prefix match"
for (const prefix in LogLinePrefixCommandMap) {
if (line.message.startsWith(prefix)) { if (line.message.startsWith(prefix)) {
return {name: 'group', prefix}; return {name: LogLinePrefixCommandMap[prefix], prefix};
}
}
for (const prefix of LogLinePrefixesEndGroup) {
if (line.message.startsWith(prefix)) {
return {name: 'endgroup', prefix};
} }
} }
return null; return null;
} }
export function shouldHideLine(line: LogLine): boolean { export function createLogLineMessage(line: LogLine, cmd: LogLineCommand | null) {
for (const prefix of LogLinePrefixesHidden) { const logMsgAttrs = {class: 'log-msg'};
if (line.message.startsWith(prefix)) { if (cmd?.name) logMsgAttrs.class += ` log-cmd-${cmd?.name}`; // make it easier to add styles to some commands like "error"
return true;
} // TODO: for some commands (::group::), the "prefix removal" works well, for some commands with "arguments" (::remove-matcher ...::),
} // it needs to do further processing in the future (fortunately, at the moment we don't need to handle these commands)
return false; const msgContent = cmd ? line.message.substring(cmd.prefix.length) : line.message;
const logMsg = createElementFromAttrs('span', logMsgAttrs);
logMsg.innerHTML = renderAnsi(msgContent);
return logMsg;
} }
function isLogElementInViewport(el: Element, {extraViewPortHeight}={extraViewPortHeight: 0}): boolean { function isLogElementInViewport(el: Element, {extraViewPortHeight}={extraViewPortHeight: 0}): boolean {
@@ -250,11 +271,7 @@ export default defineComponent({
beginLogGroup(stepIndex: number, startTime: number, line: LogLine, cmd: LogLineCommand) { beginLogGroup(stepIndex: number, startTime: number, line: LogLine, cmd: LogLineCommand) {
const el = (this.$refs.logs as any)[stepIndex] as StepContainerElement; const el = (this.$refs.logs as any)[stepIndex] as StepContainerElement;
const elJobLogGroupSummary = createElementFromAttrs('summary', {class: 'job-log-group-summary'}, const elJobLogGroupSummary = createElementFromAttrs('summary', {class: 'job-log-group-summary'},
this.createLogLine(stepIndex, startTime, { this.createLogLine(stepIndex, startTime, line, cmd),
index: line.index,
timestamp: line.timestamp,
message: line.message.substring(cmd.prefix.length),
}),
); );
const elJobLogList = createElementFromAttrs('div', {class: 'job-log-list'}); const elJobLogList = createElementFromAttrs('div', {class: 'job-log-list'});
const elJobLogGroup = createElementFromAttrs('details', {class: 'job-log-group'}, const elJobLogGroup = createElementFromAttrs('details', {class: 'job-log-group'},
@@ -268,11 +285,7 @@ export default defineComponent({
endLogGroup(stepIndex: number, startTime: number, line: LogLine, cmd: LogLineCommand) { endLogGroup(stepIndex: number, startTime: number, line: LogLine, cmd: LogLineCommand) {
const el = (this.$refs.logs as any)[stepIndex]; const el = (this.$refs.logs as any)[stepIndex];
el._stepLogsActiveContainer = null; el._stepLogsActiveContainer = null;
el.append(this.createLogLine(stepIndex, startTime, { el.append(this.createLogLine(stepIndex, startTime, line, cmd));
index: line.index,
timestamp: line.timestamp,
message: line.message.substring(cmd.prefix.length),
}));
}, },
// show/hide the step logs for a step // show/hide the step logs for a step
@@ -293,7 +306,7 @@ export default defineComponent({
POST(`${this.run.link}/approve`); POST(`${this.run.link}/approve`);
}, },
createLogLine(stepIndex: number, startTime: number, line: LogLine) { createLogLine(stepIndex: number, startTime: number, line: LogLine, cmd: LogLineCommand | null) {
const lineNum = createElementFromAttrs('a', {class: 'line-num muted', href: `#jobstep-${stepIndex}-${line.index}`}, const lineNum = createElementFromAttrs('a', {class: 'line-num muted', href: `#jobstep-${stepIndex}-${line.index}`},
String(line.index), String(line.index),
); );
@@ -302,9 +315,7 @@ export default defineComponent({
formatDatetime(new Date(line.timestamp * 1000)), // for "Show timestamps" formatDatetime(new Date(line.timestamp * 1000)), // for "Show timestamps"
); );
const logMsg = createElementFromAttrs('span', {class: 'log-msg'}); const logMsg = createLogLineMessage(line, cmd);
logMsg.innerHTML = renderAnsi(line.message);
const seconds = Math.floor(line.timestamp - startTime); const seconds = Math.floor(line.timestamp - startTime);
const logTimeSeconds = createElementFromAttrs('span', {class: 'log-time-seconds'}, const logTimeSeconds = createElementFromAttrs('span', {class: 'log-time-seconds'},
`${seconds}s`, // for "Show seconds" `${seconds}s`, // for "Show seconds"
@@ -329,17 +340,20 @@ export default defineComponent({
appendLogs(stepIndex: number, startTime: number, logLines: LogLine[]) { appendLogs(stepIndex: number, startTime: number, logLines: LogLine[]) {
for (const line of logLines) { for (const line of logLines) {
if (shouldHideLine(line)) continue; const cmd = parseLogLineCommand(line);
const el = this.getActiveLogsContainer(stepIndex); switch (cmd?.name) {
const cmd = parseLineCommand(line); case 'hidden':
if (cmd?.name === 'group') { continue;
this.beginLogGroup(stepIndex, startTime, line, cmd); case 'group':
continue; this.beginLogGroup(stepIndex, startTime, line, cmd);
} else if (cmd?.name === 'endgroup') { continue;
this.endLogGroup(stepIndex, startTime, line, cmd); case 'endgroup':
continue; this.endLogGroup(stepIndex, startTime, line, cmd);
continue;
} }
el.append(this.createLogLine(stepIndex, startTime, line)); // the active logs container may change during the loop, for example: entering and leaving a group
const el = this.getActiveLogsContainer(stepIndex);
el.append(this.createLogLine(stepIndex, startTime, line, cmd));
} }
}, },
@@ -991,6 +1005,14 @@ export default defineComponent({
overflow-wrap: anywhere; overflow-wrap: anywhere;
} }
.job-step-logs .job-log-line .log-cmd-command {
color: var(--color-ansi-blue);
}
.job-step-logs .job-log-line .log-cmd-error {
color: var(--color-ansi-red);
}
/* selectors here are intentionally exact to only match fullscreen */ /* selectors here are intentionally exact to only match fullscreen */
.full.height > .action-view-right { .full.height > .action-view-right {