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:
parent
403a73dca0
commit
915b44810d
@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
@ -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 {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user