0
0
mirror of https://github.com/go-gitea/gitea.git synced 2026-02-07 05:54:18 +01:00

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
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
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', () => {
expect(([
{index: 1, message: 'Starting build process', timestamp: 1000},
{index: 2, message: '::add-matcher::/home/runner/go/pkg/mod/example.com/tool/matcher.json', timestamp: 1001},
{index: 3, message: 'Running tests...', timestamp: 1002},
{index: 4, message: '##[add-matcher]/opt/hostedtoolcache/go/1.25.7/x64/matchers.json', timestamp: 1003},
{index: 5, message: 'Test suite started', timestamp: 1004},
{index: 7, message: 'All tests passed', timestamp: 1006},
{index: 8, message: '::remove-matcher owner=go::', timestamp: 1007},
{index: 9, message: 'Build complete', timestamp: 1008},
] as Array<LogLine>).filter((line) => !shouldHideLine(line)).map((line) => line.message)).toMatchInlineSnapshot(`
[
"Starting build process",
"Running tests...",
"Test suite started",
"All tests passed",
"Build complete",
]
`);
test('LogLineMessage', () => {
const cases = {
'normal message': '<span class="log-msg">normal message</span>',
'##[group] foo': '<span class="log-msg log-cmd-group"> foo</span>',
'::group::foo': '<span class="log-msg log-cmd-group">foo</span>',
'##[endgroup]': '<span class="log-msg log-cmd-endgroup"></span>',
'::endgroup::': '<span class="log-msg log-cmd-endgroup"></span>',
// parser shouldn't do any trim, keep origin output as-is
'##[error] foo': '<span class="log-msg log-cmd-error"> foo</span>',
'[command] foo': '<span class="log-msg log-cmd-command"> foo</span>',
// hidden is special, it is actually skipped before creating
'##[add-matcher]foo': '<span class="log-msg log-cmd-hidden">foo</span>',
'::add-matcher::foo': '<span class="log-msg log-cmd-hidden">foo</span>',
'::remove-matcher foo::': '<span class="log-msg log-cmd-hidden"> foo::</span>', // not correctly parsed, but we don't need it
};
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"
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 = {
index: number;
@ -21,19 +26,35 @@ export type LogLine = {
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 = {
name: 'group' | 'endgroup',
name: LogLineCommandName,
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 = {
id: number;
name: string;
@ -54,27 +75,27 @@ type JobStepState = {
manuallyCollapsed: boolean, // whether the user manually collapsed the step, used to avoid auto-expanding it again
}
function parseLineCommand(line: LogLine): LogLineCommand | null {
for (const prefix of LogLinePrefixesGroup) {
export function parseLogLineCommand(line: LogLine): LogLineCommand | null {
// 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)) {
return {name: 'group', prefix};
}
}
for (const prefix of LogLinePrefixesEndGroup) {
if (line.message.startsWith(prefix)) {
return {name: 'endgroup', prefix};
return {name: LogLinePrefixCommandMap[prefix], prefix};
}
}
return null;
}
export function shouldHideLine(line: LogLine): boolean {
for (const prefix of LogLinePrefixesHidden) {
if (line.message.startsWith(prefix)) {
return true;
}
}
return false;
export function createLogLineMessage(line: LogLine, cmd: LogLineCommand | null) {
const logMsgAttrs = {class: 'log-msg'};
if (cmd?.name) logMsgAttrs.class += ` log-cmd-${cmd?.name}`; // make it easier to add styles to some commands like "error"
// 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)
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 {
@ -250,11 +271,7 @@ export default defineComponent({
beginLogGroup(stepIndex: number, startTime: number, line: LogLine, cmd: LogLineCommand) {
const el = (this.$refs.logs as any)[stepIndex] as StepContainerElement;
const elJobLogGroupSummary = createElementFromAttrs('summary', {class: 'job-log-group-summary'},
this.createLogLine(stepIndex, startTime, {
index: line.index,
timestamp: line.timestamp,
message: line.message.substring(cmd.prefix.length),
}),
this.createLogLine(stepIndex, startTime, line, cmd),
);
const elJobLogList = createElementFromAttrs('div', {class: 'job-log-list'});
const elJobLogGroup = createElementFromAttrs('details', {class: 'job-log-group'},
@ -268,11 +285,7 @@ export default defineComponent({
endLogGroup(stepIndex: number, startTime: number, line: LogLine, cmd: LogLineCommand) {
const el = (this.$refs.logs as any)[stepIndex];
el._stepLogsActiveContainer = null;
el.append(this.createLogLine(stepIndex, startTime, {
index: line.index,
timestamp: line.timestamp,
message: line.message.substring(cmd.prefix.length),
}));
el.append(this.createLogLine(stepIndex, startTime, line, cmd));
},
// show/hide the step logs for a step
@ -293,7 +306,7 @@ export default defineComponent({
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}`},
String(line.index),
);
@ -302,9 +315,7 @@ export default defineComponent({
formatDatetime(new Date(line.timestamp * 1000)), // for "Show timestamps"
);
const logMsg = createElementFromAttrs('span', {class: 'log-msg'});
logMsg.innerHTML = renderAnsi(line.message);
const logMsg = createLogLineMessage(line, cmd);
const seconds = Math.floor(line.timestamp - startTime);
const logTimeSeconds = createElementFromAttrs('span', {class: 'log-time-seconds'},
`${seconds}s`, // for "Show seconds"
@ -329,17 +340,20 @@ export default defineComponent({
appendLogs(stepIndex: number, startTime: number, logLines: LogLine[]) {
for (const line of logLines) {
if (shouldHideLine(line)) continue;
const el = this.getActiveLogsContainer(stepIndex);
const cmd = parseLineCommand(line);
if (cmd?.name === 'group') {
this.beginLogGroup(stepIndex, startTime, line, cmd);
continue;
} else if (cmd?.name === 'endgroup') {
this.endLogGroup(stepIndex, startTime, line, cmd);
continue;
const cmd = parseLogLineCommand(line);
switch (cmd?.name) {
case 'hidden':
continue;
case 'group':
this.beginLogGroup(stepIndex, startTime, line, cmd);
continue;
case 'endgroup':
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;
}
.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 */
.full.height > .action-view-right {