Files
forge/web_src/js/components/ActionRunView.ts
zeekayandHanzo Dev b8db2962cd Remove the upstream name from tooling, dashboards and issue templates
The Grafana mixin queried gitea_* while modules/metrics/collector.go emits
hanzogit_*, so every stat panel rendered empty with no error. All twelve
queried names now match names the collector registers.

Issue templates sent security reports to [email protected] and help requests
to upstream's Discord and forum, which SECURITY.md explicitly tells people not
to do; both now point at [email protected] and our docs, and the issue search
points at this repository. FUNDING.yml routed sponsorship to upstream's Open
Collective, so it is gone.

The VS Code build task wrote ./gitea. The Makefile, the README beside it and
.gitignore all say gitd.

Frontend: initGiteaFomantic, initGiteaAPIViewer and isGiteaError renamed for
what they do; the Fomantic hard-fork markers read FORGE-PATCH; base.css named
:gitea: as a CUSTOM_EMOJIS example, but the defaults are git, gitlab and github.

Container paths are deliberately untouched. /data/gitea is GIT_WORK_DIR's
default in docker/root/usr/local/bin/gitd, the deployment does not set
GIT_WORK_DIR, and SSH_SERVER_HOST_KEYS is a relative path -- moving the work
directory regenerates the host keys and every existing clone reports a changed
host identity. /var/lib/gitea, /etc/gitea, gitea.db and
gitea-trusted-user-ca-keys.pem keep their names because the matching defaults
live in modules/setting, and a path renamed on one side only is a server that
starts and then cannot find its own files.

Co-authored-by: Hanzo Dev <[email protected]>
2026-09-08 19:55:51 -07:00

206 lines
6.8 KiB
TypeScript

import {createElementFromAttrs} from '../utils/dom.ts';
import {renderAnsiInto} from '../render/ansi.ts';
import {reactive} from 'vue';
import type {ActionsArtifact, ActionsJob, ActionsRun, ActionsStatus} from '../modules/actions-types.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: the 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',
'[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, commandName] of Object.entries(LogLinePrefixCommandMap)) {
if (line.message.startsWith(prefix)) {
return {name: commandName, 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');
renderAnsiInto(msgSpan, ` ${msgContent.trimStart()}`);
logMsg.append(msgSpan);
} else {
renderAnsiInto(logMsg, msgContent);
}
return logMsg;
}
// buildJobsByParentJobID groups jobs by their parentJobID (0 = top level).
// Useful for rendering the reusable-workflow caller/child tree in the sidebar.
export function buildJobsByParentJobID(jobs: ActionsJob[]): Map<number, ActionsJob[]> {
const childrenByParent = new Map<number, ActionsJob[]>();
for (const job of jobs) {
const parentID = job.parentJobID || 0;
const existing = childrenByParent.get(parentID);
if (existing) {
existing.push(job);
} else {
childrenByParent.set(parentID, [job]);
}
}
return childrenByParent;
}
export function createEmptyActionsRun(): ActionsRun {
return {
repoId: 0,
index: 0,
link: '',
viewLink: '',
title: '',
titleHTML: '',
status: '' as ActionsStatus, // 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: '',
canViewWorkflowFile: true,
isSchedule: false,
runAttempt: 0,
attempts: [],
duration: '',
triggeredAt: 0,
triggerEvent: '',
pullRequest: null,
jobs: [] as Array<ActionsJob>,
jobSummaries: [],
commit: {
localeCommit: '',
localePushedBy: '',
shortSHA: '',
link: '',
pusher: {
displayName: '',
link: '',
avatarLink: '',
},
branch: {
name: '',
link: '',
isDeleted: false,
},
},
};
}
export function createActionRunViewStore(viewUrl: string) {
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 resp = await POST(viewUrl, {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 {
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>;