mirror of
https://github.com/nexu-io/open-design.git
synced 2026-09-20 06:15:06 +08:00
462 lines
17 KiB
TypeScript
462 lines
17 KiB
TypeScript
import type { FeishuCard } from "./client.ts";
|
|
import {
|
|
loadReleaseRunContext,
|
|
loadReleaseRunFailures,
|
|
loadReleaseChangelog,
|
|
type ReleaseRunContext,
|
|
type ReleaseRunFailure,
|
|
} from "../http.ts";
|
|
|
|
type JsonRecord = Record<string, unknown>;
|
|
type FeishuElement = Record<string, unknown>;
|
|
const EXACT_RELEASE_NAME_PATTERN = /^[a-z0-9]{1,12}$/;
|
|
|
|
export function isNotificationReleaseChannel(value: unknown): value is string {
|
|
return typeof value === "string"
|
|
&& (value === "stable" || value === "prerelease" || EXACT_RELEASE_NAME_PATTERN.test(value));
|
|
}
|
|
|
|
function releaseChannelDisplayLabel(channel: string): string {
|
|
if (channel === "stable") return "Stable";
|
|
if (channel === "prerelease") return "Prerelease";
|
|
if (!EXACT_RELEASE_NAME_PATTERN.test(channel)) throw new Error(`unsupported release notification channel: ${channel}`);
|
|
return channel[0]!.toUpperCase() + channel.slice(1);
|
|
}
|
|
|
|
export type ReleaseNotificationInput = {
|
|
activationCompletedAt: string;
|
|
actor: string;
|
|
branch: string;
|
|
channel: string;
|
|
commit: string;
|
|
eventName: string;
|
|
macArm64Smoke: string;
|
|
macArm64Url: string;
|
|
macX64Smoke: string;
|
|
macX64Url: string;
|
|
metadataUrl: string;
|
|
previousCommit: string;
|
|
releaseMode: string;
|
|
releaseResult: string;
|
|
releaseState: string;
|
|
repository: string;
|
|
runAttempt: string;
|
|
runNumber: string;
|
|
runUrl: string;
|
|
stream: string;
|
|
version: string;
|
|
winX64Smoke: string;
|
|
winX64Url: string;
|
|
triggeringActor: string;
|
|
workflowName: string;
|
|
};
|
|
|
|
type ColdStartDetail = {
|
|
bodyBytes: number;
|
|
budgetBytes: number;
|
|
launcherBytes: number;
|
|
nativeBytes: number;
|
|
requiredBytes: number;
|
|
target: string;
|
|
timing?: {
|
|
readinessBudgetMs: number;
|
|
readinessDurationMs: number;
|
|
totalDurationMs: number;
|
|
};
|
|
};
|
|
|
|
export type ReleaseNotificationDetails = {
|
|
changelog: string[];
|
|
coldStarts: ColdStartDetail[];
|
|
execution: ReleaseRunContext | null;
|
|
failures: ReleaseRunFailure[];
|
|
warnings: string[];
|
|
};
|
|
|
|
function record(value: unknown): JsonRecord | null {
|
|
return value != null && typeof value === "object" && !Array.isArray(value) ? value as JsonRecord : null;
|
|
}
|
|
|
|
function positiveInteger(value: unknown): number | null {
|
|
return typeof value === "number" && Number.isSafeInteger(value) && value >= 0 ? value : null;
|
|
}
|
|
|
|
function escapeMarkdown(value: string): string {
|
|
return value.replace(/([\\`*_[\]])/gu, "\\$1");
|
|
}
|
|
|
|
function truncate(value: string, maxLength: number): string {
|
|
return value.length <= maxLength ? value : `${value.slice(0, maxLength - 1).trimEnd()}…`;
|
|
}
|
|
|
|
function notificationState(input: ReleaseNotificationInput): "candidate" | "failed" | "partial" | "published" | "promoted" | "validation" {
|
|
if (input.releaseResult !== "success") return "failed";
|
|
if (input.releaseState === "partial") return "partial";
|
|
if (input.releaseMode === "candidate") return "candidate";
|
|
if (["metadata", "prepublish", "validation", "false"].includes(input.releaseMode)) return "validation";
|
|
if (input.version.length === 0 || input.metadataUrl.length === 0) return "validation";
|
|
return input.releaseMode === "promote" ? "promoted" : "published";
|
|
}
|
|
|
|
function coldStartFromMetadata(metadata: JsonRecord): ColdStartDetail[] {
|
|
const closure = record(metadata.closure);
|
|
const required = record(closure?.required);
|
|
const targets = record(required?.targets);
|
|
const blobs = record(closure?.blobs);
|
|
const body = record(required?.body);
|
|
const launcher = record(required?.launcher);
|
|
if (targets == null || blobs == null || body == null || launcher == null) return [];
|
|
const component = (value: JsonRecord | null): { digest: string; bytes: number } | null => {
|
|
const digest = typeof value?.blob === "string" ? value.blob : "";
|
|
const artifact = record(blobs[digest]);
|
|
const bytes = positiveInteger(artifact?.size);
|
|
return digest.length > 0 && bytes != null ? { bytes, digest } : null;
|
|
};
|
|
const commonBody = component(body);
|
|
const commonLauncher = component(launcher);
|
|
if (commonBody == null || commonLauncher == null) return [];
|
|
return Object.entries(targets).flatMap(([target, targetValue]) => {
|
|
const native = component(record(record(targetValue)?.native));
|
|
if (native == null) return [];
|
|
const unique = new Map([
|
|
[commonBody.digest, commonBody.bytes],
|
|
[commonLauncher.digest, commonLauncher.bytes],
|
|
[native.digest, native.bytes],
|
|
]);
|
|
return [{
|
|
bodyBytes: commonBody.bytes,
|
|
budgetBytes: 30_000_000,
|
|
launcherBytes: commonLauncher.bytes,
|
|
nativeBytes: native.bytes,
|
|
requiredBytes: [...unique.values()].reduce((total, bytes) => total + bytes, 0),
|
|
target,
|
|
}];
|
|
});
|
|
}
|
|
|
|
async function acceptanceTiming(metadataUrl: string, target: string, fetchImpl: typeof fetch) {
|
|
const acceptanceUrl = new URL(`acceptance/${target.replace("darwin-arm64", "mac_arm64").replace("darwin-x64", "mac_x64").replace("win32-x64", "win_x64")}.json`, metadataUrl);
|
|
const response = await fetchImpl(acceptanceUrl);
|
|
if (!response.ok) return undefined;
|
|
const credential = record(await response.json());
|
|
const timing = record(record(credential?.coldStart)?.timing);
|
|
const readinessBudgetMs = positiveInteger(timing?.readinessBudgetMs);
|
|
const readinessDurationMs = positiveInteger(timing?.readinessDurationMs);
|
|
const totalDurationMs = positiveInteger(timing?.totalDurationMs);
|
|
return readinessBudgetMs == null || readinessDurationMs == null || totalDurationMs == null
|
|
? undefined
|
|
: { readinessBudgetMs, readinessDurationMs, totalDurationMs };
|
|
}
|
|
|
|
export async function loadReleaseNotificationDetails(
|
|
input: ReleaseNotificationInput,
|
|
fetchImpl: typeof fetch = fetch,
|
|
githubToken = "",
|
|
): Promise<ReleaseNotificationDetails> {
|
|
const state = notificationState(input);
|
|
const smokeFailed = [input.macArm64Smoke, input.macX64Smoke, input.winX64Smoke].includes("failure");
|
|
let execution: ReleaseRunContext | null = null;
|
|
let changelog: string[] = [];
|
|
try {
|
|
[execution, changelog] = await Promise.all([
|
|
loadReleaseRunContext({
|
|
fetchImpl,
|
|
repository: input.repository,
|
|
runUrl: input.runUrl,
|
|
token: githubToken,
|
|
}),
|
|
loadReleaseChangelog({
|
|
commit: input.commit,
|
|
fetchImpl,
|
|
previousCommit: input.previousCommit,
|
|
repository: input.repository,
|
|
token: githubToken,
|
|
}),
|
|
]);
|
|
} catch {
|
|
// Execution metadata is presentational. A GitHub API outage must not turn a
|
|
// successful release card into a warning or change the release outcome.
|
|
}
|
|
if (!["failed", "partial"].includes(state) && !smokeFailed) {
|
|
return { changelog, coldStarts: [], execution, failures: [], warnings: [] };
|
|
}
|
|
const warnings: string[] = [];
|
|
let coldStarts: ColdStartDetail[] = [];
|
|
let failures: ReleaseRunFailure[] = [];
|
|
if (input.metadataUrl.length > 0) {
|
|
try {
|
|
const response = await fetchImpl(input.metadataUrl);
|
|
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
|
const metadata = record(await response.json());
|
|
if (metadata == null) throw new Error("metadata is not an object");
|
|
coldStarts = coldStartFromMetadata(metadata);
|
|
if (input.channel !== "stable" && input.channel !== "prerelease") {
|
|
await Promise.all(coldStarts.map(async (entry) => {
|
|
const timing = await acceptanceTiming(input.metadataUrl, entry.target, fetchImpl);
|
|
if (timing != null) entry.timing = timing;
|
|
}));
|
|
}
|
|
} catch (error) {
|
|
warnings.push(`未能读取发布元数据:${error instanceof Error ? error.message : String(error)}`);
|
|
}
|
|
}
|
|
if (state === "failed") {
|
|
try {
|
|
failures = await loadReleaseRunFailures({
|
|
fetchImpl,
|
|
repository: input.repository,
|
|
runUrl: input.runUrl,
|
|
token: githubToken,
|
|
});
|
|
} catch (error) {
|
|
warnings.push(`未能读取失败步骤:${error instanceof Error ? error.message : String(error)}`);
|
|
}
|
|
}
|
|
return { changelog, coldStarts, execution, failures, warnings };
|
|
}
|
|
|
|
function bytes(value: number): string {
|
|
return `${(value / 1_000_000).toFixed(2)} MB`;
|
|
}
|
|
|
|
function seconds(value: number): string {
|
|
return `${(value / 1_000).toFixed(1)}s`;
|
|
}
|
|
|
|
function duration(value: number): string {
|
|
const totalSeconds = Math.max(0, Math.round(value / 1_000));
|
|
const hours = Math.floor(totalSeconds / 3_600);
|
|
const minutes = Math.floor((totalSeconds % 3_600) / 60);
|
|
const seconds = totalSeconds % 60;
|
|
return [hours > 0 ? `${hours}h` : "", minutes > 0 ? `${minutes}m` : "", `${seconds}s`]
|
|
.filter(Boolean)
|
|
.join(" ");
|
|
}
|
|
|
|
function actorMarkdown(actor: string): string {
|
|
const escaped = escapeMarkdown(actor);
|
|
return actor.length > 0
|
|
? `[@${escaped}](https://github.com/${encodeURIComponent(actor)})`
|
|
: "未知";
|
|
}
|
|
|
|
function commitMarkdown(input: ReleaseNotificationInput): string {
|
|
const shortCommit = input.commit.slice(0, 7);
|
|
if (shortCommit.length === 0) return "";
|
|
return input.repository.length > 0
|
|
? `[${shortCommit}](https://github.com/${input.repository}/commit/${input.commit})`
|
|
: shortCommit;
|
|
}
|
|
|
|
function executionTimingMarkdown(input: ReleaseNotificationInput, execution: ReleaseRunContext | null): string {
|
|
if (execution == null) return "";
|
|
const activationMs = Date.parse(input.activationCompletedAt);
|
|
const hasActivation = Number.isFinite(activationMs)
|
|
&& activationMs >= execution.runStartedAtMs
|
|
&& activationMs <= execution.observedAtMs;
|
|
const releaseMs = hasActivation ? activationMs - execution.runStartedAtMs : execution.durationMs;
|
|
const notificationMs = hasActivation ? execution.observedAtMs - activationMs : 0;
|
|
return [
|
|
`发布 ${duration(releaseMs)}`,
|
|
hasActivation ? `通知 ${duration(notificationMs)}` : "",
|
|
execution.queueDurationMs > 0 ? `排队 ${duration(execution.queueDurationMs)}` : "",
|
|
].filter(Boolean).join(" · ");
|
|
}
|
|
|
|
function triggerLabel(eventName: string): string {
|
|
return {
|
|
push: "Push",
|
|
repository_dispatch: "外部调度",
|
|
schedule: "定时",
|
|
workflow_call: "复用工作流",
|
|
workflow_dispatch: "手动",
|
|
workflow_run: "上游工作流",
|
|
}[eventName] ?? (eventName.length > 0 ? eventName : "未知");
|
|
}
|
|
|
|
function releaseModeLabel(mode: string): string {
|
|
if (mode === "candidate") return "publish=false · 公开候选 / 无 latest 入口";
|
|
if (["false", "metadata", "prepublish", "validation"].includes(mode)) return "publish=false · 内部验证";
|
|
if (mode === "promote") return "publish=true · 晋升 latest";
|
|
return "publish=true · 不晋升";
|
|
}
|
|
|
|
function targetLabel(target: string): string {
|
|
return {
|
|
"darwin-arm64": "Apple 芯片",
|
|
"darwin-x64": "Intel",
|
|
"win32-x64": "Windows",
|
|
}[target] ?? target;
|
|
}
|
|
|
|
function coldStartMarkdown(details: ReleaseNotificationDetails): string {
|
|
return details.coldStarts.map((entry) => {
|
|
const timing = entry.timing == null
|
|
? ""
|
|
: `\n启动 ${seconds(entry.timing.totalDurationMs - entry.timing.readinessDurationMs)}`
|
|
+ ` · 就绪 ${seconds(entry.timing.readinessDurationMs)}/${seconds(entry.timing.readinessBudgetMs)}`
|
|
+ ` · 总计 ${seconds(entry.timing.totalDurationMs)}`;
|
|
return `**${targetLabel(entry.target)}** · ${bytes(entry.requiredBytes)} / ${bytes(entry.budgetBytes)}`
|
|
+ `\nbody ${bytes(entry.bodyBytes)} · launcher ${bytes(entry.launcherBytes)} · native ${bytes(entry.nativeBytes)}`
|
|
+ timing;
|
|
}).join("\n\n");
|
|
}
|
|
|
|
function failureMarkdown(details: ReleaseNotificationDetails): string {
|
|
return details.failures.map((failure) => {
|
|
const label = truncate(
|
|
failure.step.length > 0 ? `${failure.job} · ${failure.step}` : failure.job,
|
|
100,
|
|
);
|
|
return failure.url.length > 0
|
|
? `- [${escapeMarkdown(label)}](${failure.url})`
|
|
: `- ${escapeMarkdown(label)}`;
|
|
}).join("\n");
|
|
}
|
|
|
|
function changelogMarkdown(lines: string[], repository: string): string {
|
|
return lines.slice(0, 5).map((line) => {
|
|
const match = line.match(/^(.*) \(([0-9a-f]{7,40})\)$/u);
|
|
const subject = truncate(match?.[1]?.trim() || line, 90);
|
|
const commit = match?.[2] ?? "";
|
|
const suffix = commit.length === 0
|
|
? ""
|
|
: repository.length > 0
|
|
? ` · [${commit.slice(0, 7)}](https://github.com/${repository}/commit/${commit})`
|
|
: ` · ${commit.slice(0, 7)}`;
|
|
return `- ${escapeMarkdown(subject)}${suffix}`;
|
|
}).join("\n");
|
|
}
|
|
|
|
export function buildReleaseFeishuCard(
|
|
input: ReleaseNotificationInput,
|
|
details: ReleaseNotificationDetails,
|
|
): FeishuCard {
|
|
const state = notificationState(input);
|
|
const channelLabel = releaseChannelDisplayLabel(input.channel);
|
|
const smokeFailures = [
|
|
["macOS arm64", input.macArm64Smoke],
|
|
["macOS x64", input.macX64Smoke],
|
|
["Windows x64", input.winX64Smoke],
|
|
].filter(([, result]) => result === "failure").map(([label]) => `${label} smoke 失败`);
|
|
const warning = state === "partial" || smokeFailures.length > 0 || details.warnings.length > 0;
|
|
const icon = state === "failed" ? "🚨" : state === "validation" ? "🧪" : state === "candidate" ? "📦" : warning ? "⚠️" : "🚀";
|
|
const stateLabel = {
|
|
candidate: warning ? "候选就绪(有告警)" : "候选就绪 · 公开下载 / 无 latest 入口",
|
|
failed: "发布失败",
|
|
partial: "部分完成",
|
|
promoted: warning ? "发布并晋升完成(有告警)" : "发布并晋升完成",
|
|
published: warning ? "不可变发布完成(有告警)" : "不可变发布完成 · 未晋升",
|
|
validation: "验证完成",
|
|
}[state];
|
|
const fields: FeishuElement[] = [];
|
|
const rerunActor = input.triggeringActor.length > 0 && input.triggeringActor !== input.actor
|
|
? ` · 重跑 ${actorMarkdown(input.triggeringActor)}`
|
|
: "";
|
|
const source = [escapeMarkdown(input.branch), commitMarkdown(input)].filter(Boolean).join(" · ");
|
|
fields.push({
|
|
is_short: false,
|
|
text: {
|
|
tag: "lark_md",
|
|
content: `**触发** · ${actorMarkdown(input.actor)}${rerunActor} · ${escapeMarkdown(triggerLabel(input.eventName))}`
|
|
+ (source.length > 0 ? ` · ${source}` : ""),
|
|
},
|
|
});
|
|
fields.push({
|
|
is_short: false,
|
|
text: { tag: "lark_md", content: `**模式** · ${releaseModeLabel(input.releaseMode)}` },
|
|
});
|
|
if (input.runNumber.length > 0) {
|
|
const attempt = Number.parseInt(input.runAttempt, 10);
|
|
const attemptLabel = Number.isSafeInteger(attempt) && attempt > 1 ? ` · 第 ${attempt} 次执行` : "";
|
|
const runLabel = `${input.workflowName || "Workflow"} #${input.runNumber}${attemptLabel}`;
|
|
fields.push({
|
|
is_short: false,
|
|
text: {
|
|
tag: "lark_md",
|
|
content: `**执行** · ${input.runUrl.length > 0
|
|
? `[${escapeMarkdown(runLabel)}](${input.runUrl})`
|
|
: escapeMarkdown(runLabel)}`
|
|
+ (details.execution?.pullRequest != null
|
|
? ` · [PR #${details.execution.pullRequest.number}](${details.execution.pullRequest.url})`
|
|
: "")
|
|
+ (details.execution != null
|
|
? ` · ${executionTimingMarkdown(input, details.execution)}`
|
|
: ""),
|
|
},
|
|
});
|
|
}
|
|
const elements: FeishuElement[] = fields.length > 0
|
|
? [{ tag: "div", fields }, { tag: "hr" }]
|
|
: [];
|
|
const notices = [...smokeFailures, ...details.warnings];
|
|
if (details.failures.length > 0) elements.push({
|
|
tag: "div",
|
|
text: { tag: "lark_md", content: `**失败位置**\n${failureMarkdown(details)}` },
|
|
});
|
|
if (notices.length > 0) elements.push({
|
|
tag: "div",
|
|
text: {
|
|
tag: "lark_md",
|
|
content: `**告警**\n${notices.map((line) => `- ${escapeMarkdown(line)}`).join("\n")}`,
|
|
},
|
|
});
|
|
if (details.coldStarts.length > 0) elements.push({ tag: "div", text: { tag: "lark_md", content: `**Closure 冷启动**\n${coldStartMarkdown(details)}` } });
|
|
if (details.changelog.length > 0) elements.push({
|
|
tag: "div",
|
|
text: {
|
|
tag: "lark_md",
|
|
content: `**变更**\n${changelogMarkdown(details.changelog, input.repository)}`,
|
|
},
|
|
});
|
|
const downloads = ([
|
|
["Mac Arm", input.macArm64Url],
|
|
["Mac x64", input.macX64Url],
|
|
["Win x64", input.winX64Url],
|
|
] satisfies Array<[string, string]>).filter(([, url]) => url.length > 0);
|
|
if (downloads.length > 0 && state !== "failed") elements.push({
|
|
tag: "action",
|
|
actions: downloads.map(([label, url], index) => ({
|
|
tag: "button",
|
|
text: { tag: "plain_text", content: state === "candidate" ? `候选 · ${label}` : label },
|
|
type: index === 0 ? "primary" : "default",
|
|
url,
|
|
})),
|
|
});
|
|
if (state === "candidate" && input.repository.length > 0) {
|
|
const workflow = input.channel === "stable"
|
|
? "release-stable-promote.yml"
|
|
: "distribution-exact-accept.yml";
|
|
elements.push({
|
|
tag: "action",
|
|
actions: [{
|
|
tag: "button",
|
|
text: { tag: "plain_text", content: "发布 / 晋升候选" },
|
|
type: "primary",
|
|
url: `https://github.com/${input.repository}/actions/workflows/${workflow}`,
|
|
}],
|
|
});
|
|
}
|
|
const links = [
|
|
input.metadataUrl.length > 0 ? `[${state === "candidate" ? "候选清单" : "发布详情"}](${input.metadataUrl})` : "",
|
|
input.runUrl.length > 0 ? `[GitHub Actions](${input.runUrl})` : "",
|
|
].filter(Boolean);
|
|
if (links.length > 0) elements.push({
|
|
tag: "note",
|
|
elements: [{ tag: "lark_md", content: links.join(" · ") }],
|
|
});
|
|
return {
|
|
config: { wide_screen_mode: true },
|
|
header: {
|
|
template: state === "failed" ? "red" : warning ? "orange" : state === "published" || state === "promoted" ? "green" : "blue",
|
|
title: {
|
|
tag: "plain_text",
|
|
content: `${icon} ${channelLabel} ${input.version || "(未生成版本)"} ${stateLabel}`,
|
|
},
|
|
},
|
|
elements,
|
|
};
|
|
}
|
|
|
|
export const releaseNotificationInternals = { coldStartFromMetadata, notificationState };
|