mirror of
https://github.com/go-gitea/gitea.git
synced 2026-02-07 09:49:41 +09: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:
@@ -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);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user