mirror of
https://github.com/go-gitea/gitea.git
synced 2026-03-26 14:20:29 +01:00
Add support for error, warning, notice, and debug log commands with bold label prefixes and colored backgrounds matching GitHub's style. Parse both `##[cmd]` and `::cmd args::` formats. Also improved the severity colors globally and added a devtest page for these. --------- Co-authored-by: Claude (claude-opus-4-6) <noreply@anthropic.com>
181 lines
6.1 KiB
TypeScript
181 lines
6.1 KiB
TypeScript
import {createElementFromAttrs} from '../utils/dom.ts';
|
|
import {renderAnsi} from '../render/ansi.ts';
|
|
import {reactive} from 'vue';
|
|
import type {ActionsArtifact, ActionsJob, ActionsRun, ActionsRunStatus} from '../modules/gitea-actions.ts';
|
|
import type {IntervalId} from '../types.ts';
|
|
import {POST} from '../modules/fetch.ts';
|
|
|
|
// 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',
|
|
'##[warning]': 'warning',
|
|
'##[notice]': 'notice',
|
|
'##[debug]': 'debug',
|
|
'[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
|
|
};
|
|
|
|
// Pattern for ::cmd:: and ::cmd args:: format (args are stripped for display)
|
|
const LogLineCmdPattern = /^::(error|warning|notice|debug)(?:\s[^:]*)?::/;
|
|
|
|
export type LogLine = {
|
|
index: number;
|
|
timestamp: number;
|
|
message: string;
|
|
};
|
|
|
|
export type LogLineCommandName = 'group' | 'endgroup' | 'command' | 'error' | 'warning' | 'notice' | 'debug' | 'hidden';
|
|
export type LogLineCommand = {
|
|
name: LogLineCommandName,
|
|
prefix: string,
|
|
};
|
|
|
|
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 of Object.keys(LogLinePrefixCommandMap)) {
|
|
if (line.message.startsWith(prefix)) {
|
|
return {name: LogLinePrefixCommandMap[prefix], prefix};
|
|
}
|
|
}
|
|
// Handle ::cmd:: and ::cmd args:: format (runner may pass these through raw)
|
|
const match = LogLineCmdPattern.exec(line.message);
|
|
if (match) {
|
|
return {name: match[1] as LogLineCommandName, prefix: match[0]};
|
|
}
|
|
return null;
|
|
}
|
|
|
|
const LogLineLabelMap: Partial<Record<LogLineCommandName, string>> = {
|
|
'error': 'Error',
|
|
'warning': 'Warning',
|
|
'notice': 'Notice',
|
|
'debug': 'Debug',
|
|
};
|
|
|
|
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);
|
|
const label = cmd ? LogLineLabelMap[cmd.name] : null;
|
|
if (label) {
|
|
logMsg.append(createElementFromAttrs('span', {class: 'log-msg-label'}, `${label}:`));
|
|
const msgSpan = document.createElement('span');
|
|
msgSpan.innerHTML = ` ${renderAnsi(msgContent.trimStart())}`;
|
|
logMsg.append(msgSpan);
|
|
} else {
|
|
logMsg.innerHTML = renderAnsi(msgContent);
|
|
}
|
|
return logMsg;
|
|
}
|
|
|
|
export function createEmptyActionsRun(): ActionsRun {
|
|
return {
|
|
link: '',
|
|
title: '',
|
|
titleHTML: '',
|
|
status: '' as ActionsRunStatus, // do not show the status before initialized, otherwise it would show an incorrect "error" icon
|
|
canCancel: false,
|
|
canApprove: false,
|
|
canRerun: false,
|
|
canRerunFailed: false,
|
|
canDeleteArtifact: false,
|
|
done: false,
|
|
workflowID: '',
|
|
workflowLink: '',
|
|
isSchedule: false,
|
|
duration: '',
|
|
triggeredAt: 0,
|
|
triggerEvent: '',
|
|
jobs: [] as Array<ActionsJob>,
|
|
commit: {
|
|
localeCommit: '',
|
|
localePushedBy: '',
|
|
shortSHA: '',
|
|
link: '',
|
|
pusher: {
|
|
displayName: '',
|
|
link: '',
|
|
},
|
|
branch: {
|
|
name: '',
|
|
link: '',
|
|
isDeleted: false,
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
export function createActionRunViewStore(actionsUrl: string, runId: number) {
|
|
let loadingAbortController: AbortController | null = null;
|
|
let intervalID: IntervalId | null = null;
|
|
const viewData = reactive({
|
|
currentRun: createEmptyActionsRun(),
|
|
runArtifacts: [] as Array<ActionsArtifact>,
|
|
});
|
|
const loadCurrentRun = async () => {
|
|
if (loadingAbortController) return;
|
|
const abortController = new AbortController();
|
|
loadingAbortController = abortController;
|
|
try {
|
|
const url = `${actionsUrl}/runs/${runId}`;
|
|
const resp = await POST(url, {signal: abortController.signal, data: {}});
|
|
const runResp = await resp.json();
|
|
if (loadingAbortController !== abortController) return;
|
|
|
|
viewData.runArtifacts = runResp.artifacts || [];
|
|
viewData.currentRun = runResp.state.run;
|
|
// clear the interval timer if the job is done
|
|
if (viewData.currentRun.done && intervalID) {
|
|
clearInterval(intervalID);
|
|
intervalID = null;
|
|
}
|
|
} catch (e) {
|
|
// avoid network error while unloading page, and ignore "abort" error
|
|
if (e instanceof TypeError || abortController.signal.aborted) return;
|
|
throw e;
|
|
} finally {
|
|
if (loadingAbortController === abortController) loadingAbortController = null;
|
|
}
|
|
};
|
|
|
|
return reactive({
|
|
viewData,
|
|
|
|
async startPollingCurrentRun() {
|
|
await loadCurrentRun();
|
|
intervalID = setInterval(() => loadCurrentRun(), 1000);
|
|
},
|
|
async forceReloadCurrentRun() {
|
|
loadingAbortController?.abort();
|
|
loadingAbortController = null;
|
|
await loadCurrentRun();
|
|
},
|
|
stopPollingCurrentRun() {
|
|
if (!intervalID) return;
|
|
clearInterval(intervalID);
|
|
intervalID = null;
|
|
},
|
|
});
|
|
}
|
|
|
|
export type ActionRunViewStore = ReturnType<typeof createActionRunViewStore>;
|