feat: enrich hwlab rollout briefs

This commit is contained in:
Codex
2026-05-27 09:24:42 +00:00
parent 8b03898bc0
commit 18f49922ba
7 changed files with 1074 additions and 9 deletions
+306 -7
View File
@@ -58,6 +58,30 @@ interface OpenPullRequest {
draft?: boolean;
}
interface CiServiceMetric {
taskRun: string;
serviceId: string;
status: string;
durationSeconds: number | null;
buildBackend: string | null;
reusedFrom: string | null;
imageTag: string | null;
sourceCommitId: string | null;
}
interface CiPipelineMetrics {
ok: boolean;
pipelineRun: string;
serviceTotal: number;
reusedCount: number;
rebuildCount: number;
reusedServices: string[];
rebuildServices: string[];
services: CiServiceMetric[];
degradedReason?: string;
rawText?: string;
}
export function hwlabG14MonitorStateFileName(options: Pick<G14MonitorOptions, "once" | "dryRun">): string {
if (options.once && options.dryRun) return "latest-once-dry-run-job.json";
if (options.once) return "latest-once-job.json";
@@ -317,6 +341,272 @@ function formatDuration(seconds: number | null): string {
return `${minutes}m${String(rest).padStart(2, "0")}s`;
}
function pipelineTaskRunResultsJsonPath(): string {
return [
"jsonpath=",
"{range .items[*]}",
"{.metadata.name}{\"\\t\"}",
"{.metadata.labels.tekton\\.dev/pipelineTask}{\"\\t\"}",
"{.status.startTime}{\"\\t\"}",
"{.status.completionTime}{\"\\t\"}",
"{range .status.results[*]}{.name}{\"=\"}{.value}{\";\"}{end}",
"{\"\\n\"}",
"{end}",
].join("");
}
function getPipelineTaskRunResults(pipelineRun: string): CommandJsonResult {
return cliJson([
"ssh",
`${G14_PROVIDER}:k3s`,
"kubectl",
"get",
"taskrun",
"-n",
CI_NAMESPACE,
"-l",
`tekton.dev/pipelineRun=${pipelineRun}`,
"-o",
pipelineTaskRunResultsJsonPath(),
], 80_000);
}
function parseResultMap(raw: string): Record<string, string> {
const values: Record<string, string> = {};
for (const item of raw.split(";")) {
if (item.length === 0) continue;
const separator = item.indexOf("=");
if (separator <= 0) continue;
values[item.slice(0, separator)] = item.slice(separator + 1);
}
return values;
}
function serviceMetricSort(left: CiServiceMetric, right: CiServiceMetric): number {
return left.serviceId.localeCompare(right.serviceId);
}
export function parsePipelineTaskRunMetrics(pipelineRun: string, text: string): CiPipelineMetrics {
const services: CiServiceMetric[] = [];
for (const line of text.split(/\r?\n/u)) {
const trimmed = line.trimEnd();
if (trimmed.length === 0) continue;
const [taskRunName = "", pipelineTask = "", startTime = "", completionTime = "", resultsRaw = ""] = trimmed.split("\t");
if (!pipelineTask.startsWith("build-")) continue;
const results = parseResultMap(resultsRaw);
const serviceId = results["service-id"] ?? pipelineTask.replace(/^build-/u, "");
if (serviceId.length === 0) continue;
services.push({
taskRun: taskRunName,
serviceId,
status: results.status ?? "unknown",
durationSeconds: durationSeconds(startTime, completionTime),
buildBackend: results["build-backend"] ?? null,
reusedFrom: results["reused-from"] ?? null,
imageTag: results["image-tag"] ?? null,
sourceCommitId: results["source-commit-id"] ?? null,
});
}
services.sort(serviceMetricSort);
const reusedServices = services.filter((service) => service.status === "reused").map((service) => service.serviceId);
const rebuildServices = services.filter((service) => service.status !== "reused").map((service) => service.serviceId);
return {
ok: services.length > 0,
pipelineRun,
serviceTotal: services.length,
reusedCount: reusedServices.length,
rebuildCount: rebuildServices.length,
reusedServices,
rebuildServices,
services,
...(services.length === 0 ? { degradedReason: "no-build-task-results", rawText: text.slice(0, 2000) } : {}),
};
}
function ciMetricsFromValue(value: unknown): CiPipelineMetrics | null {
const data = record(value);
if (data.ok !== true || typeof data.pipelineRun !== "string") return null;
const services = Array.isArray(data.services) ? data.services.map((item) => {
const service = record(item);
return {
taskRun: String(service.taskRun ?? ""),
serviceId: String(service.serviceId ?? ""),
status: String(service.status ?? "unknown"),
durationSeconds: typeof service.durationSeconds === "number" ? service.durationSeconds : null,
buildBackend: typeof service.buildBackend === "string" ? service.buildBackend : null,
reusedFrom: typeof service.reusedFrom === "string" ? service.reusedFrom : null,
imageTag: typeof service.imageTag === "string" ? service.imageTag : null,
sourceCommitId: typeof service.sourceCommitId === "string" ? service.sourceCommitId : null,
};
}).filter((service) => service.serviceId.length > 0).sort(serviceMetricSort) : [];
const reusedServices = Array.isArray(data.reusedServices) ? data.reusedServices.map(String) : services.filter((service) => service.status === "reused").map((service) => service.serviceId);
const rebuildServices = Array.isArray(data.rebuildServices) ? data.rebuildServices.map(String) : services.filter((service) => service.status !== "reused").map((service) => service.serviceId);
return {
ok: true,
pipelineRun: data.pipelineRun,
serviceTotal: typeof data.serviceTotal === "number" ? data.serviceTotal : services.length,
reusedCount: typeof data.reusedCount === "number" ? data.reusedCount : reusedServices.length,
rebuildCount: typeof data.rebuildCount === "number" ? data.rebuildCount : rebuildServices.length,
reusedServices,
rebuildServices,
services,
};
}
function collectPipelineMetrics(pipelineRun: string): CiPipelineMetrics {
const result = getPipelineTaskRunResults(pipelineRun);
if (!isCommandSuccess(result)) {
return {
ok: false,
pipelineRun,
serviceTotal: 0,
reusedCount: 0,
rebuildCount: 0,
reusedServices: [],
rebuildServices: [],
services: [],
degradedReason: "taskrun-query-failed",
rawText: `${result.stderr}\n${result.stdout}`.trim().slice(0, 2000),
};
}
return parsePipelineTaskRunMetrics(pipelineRun, statusText(result));
}
function ciMetricLines(metrics: CiPipelineMetrics | null): string[] {
if (metrics === null) {
return [
"- CI/CD 关键指标:",
" - lazy build reused: n/a",
" - reused services: n/a",
" - rebuild services: n/a",
" - service durations: n/a",
];
}
if (metrics.ok !== true) {
return [
"- CI/CD 关键指标:",
` - lazy build reused: unavailable (${metrics.degradedReason ?? "unknown"})`,
" - reused services: n/a",
" - rebuild services: n/a",
" - service durations: n/a",
];
}
const lines = [
"- CI/CD 关键指标:",
` - lazy build reused: ${metrics.reusedCount}/${metrics.serviceTotal}`,
` - reused services: ${metrics.reusedServices.length > 0 ? metrics.reusedServices.join(", ") : "none"}`,
` - rebuild services: ${metrics.rebuildServices.length > 0 ? metrics.rebuildServices.join(", ") : "none"}`,
" - service durations:",
];
for (const service of metrics.services) {
const backend = service.buildBackend === null ? "" : `, backend=${service.buildBackend}`;
lines.push(` - ${service.serviceId}: ${service.status}, ${formatDuration(service.durationSeconds)}${backend}`);
}
return lines;
}
function stripMarkdownInline(value: string): string {
return value
.replace(/\[([^\]]+)\]\([^)]+\)/gu, "$1")
.replace(/\*\*([^*]+)\*\*/gu, "$1")
.replace(/\*([^*]+)\*/gu, "$1")
.replace(/__([^_]+)__/gu, "$1")
.replace(/_([^_]+)_/gu, "$1")
.replace(/\s+/gu, " ")
.trim();
}
function markdownSection(body: string, headings: string[]): string {
const normalizedHeadings = new Set(headings.map((heading) => heading.toLowerCase()));
const lines = body.split(/\r?\n/u);
let capturing = false;
const captured: string[] = [];
for (const line of lines) {
const heading = /^(#{1,6})\s+(.+?)\s*$/u.exec(line);
if (heading !== null) {
const title = stripMarkdownInline(heading[2] ?? "").replace(/[:]\s*$/u, "").toLowerCase();
if (capturing) break;
capturing = normalizedHeadings.has(title);
continue;
}
if (capturing) captured.push(line);
}
return captured.join("\n").trim();
}
function firstParagraph(section: string): string | null {
for (const paragraph of section.split(/\n\s*\n/u)) {
const trimmed = stripMarkdownInline(paragraph.replace(/^[-*]\s+/gmu, ""));
if (trimmed.length > 0) return trimmed;
}
return null;
}
function topLevelBullets(section: string, limit = 8): string[] {
const bullets: string[] = [];
for (const line of section.split(/\r?\n/u)) {
const match = /^[-*]\s+(.+)$/u.exec(line);
if (match === null) continue;
const text = stripMarkdownInline(match[1] ?? "");
if (text.length > 0) bullets.push(text);
if (bullets.length >= limit) break;
}
return bullets;
}
function conventionalTitleSummary(title: string): string {
const match = /^(feat|fix|docs|test|refactor|perf|ci|build|chore)(?:\([^)]+\))?:\s*(.+)$/iu.exec(title.trim());
if (match === null) return stripMarkdownInline(title);
const kind = String(match[1] ?? "").toLowerCase();
const subject = stripMarkdownInline(match[2] ?? title);
const labels: Record<string, string> = {
feat: "功能",
fix: "修复",
docs: "文档",
test: "测试",
refactor: "重构",
perf: "性能",
ci: "CI/CD",
build: "构建",
chore: "维护",
};
return `${labels[kind] ?? "变更"}${subject}`;
}
function fileHeuristicBullets(fileSummary: Record<string, unknown>): string[] {
const keyFiles = Array.isArray(fileSummary.keyFiles) ? fileSummary.keyFiles.map(String) : [];
const bullets: string[] = [];
if (keyFiles.some((file) => file.includes("web/hwlab-cloud-web/"))) {
bullets.push("前端:更新 HWLAB Cloud Web / Workbench 交互行为。");
}
if (keyFiles.some((file) => /\.(test|spec)\./u.test(file) || file.includes("/test"))) {
bullets.push("验证:补充或更新回归测试,覆盖本次变更路径。");
}
if (keyFiles.some((file) => file.includes("deploy/") || file.includes("k8s") || file.includes("gitops"))) {
bullets.push("部署:更新 G14 Kubernetes/GitOps 发布声明或发布计划。");
}
if (keyFiles.some((file) => file.includes("internal/cloud/") || file.includes("code-agent"))) {
bullets.push("运行时:调整 Cloud API / Code Agent 运行链路。");
}
return bullets;
}
export function semanticChangelogBullets(title: string, body: string, fileSummary: Record<string, unknown>): string[] {
const bullets: string[] = [];
const background = firstParagraph(markdownSection(body, ["背景", "问题", "problem", "background", "context"]));
if (background !== null && /^(fix|perf)(?:\([^)]+\))?:/iu.test(title)) {
bullets.push(`修复目标:${background}`);
}
const changeSection = markdownSection(body, ["上线 changelog", "changelog", "摘要", "概述", "总结", "修改", "变更", "改动", "changes", "what changed", "summary"]);
bullets.push(...topLevelBullets(changeSection));
if (bullets.length === 0) bullets.push(conventionalTitleSummary(title));
for (const bullet of fileHeuristicBullets(fileSummary)) {
if (bullets.length >= 8) break;
if (!bullets.includes(bullet)) bullets.push(bullet);
}
return bullets.slice(0, 8);
}
function readIssue(issueNumber: number): CommandJsonResult {
return cliJson(["gh", "issue", "read", String(issueNumber), "--repo", HWLAB_REPO, "--json", "title,body,state,updatedAt", "--raw"], 80_000);
}
@@ -425,7 +715,7 @@ function ensureDailyBriefIssue(date: string, dryRun: boolean): Record<string, un
}
function readPullRequest(number: number): CommandJsonResult {
return cliJson(["gh", "pr", "read", String(number), "--repo", HWLAB_REPO, "--json", "title,url,stateDetail,merged,mergedAt,mergeCommit,headRefName,baseRefName"], 80_000);
return cliJson(["gh", "pr", "read", String(number), "--repo", HWLAB_REPO, "--json", "title,body,url,stateDetail,merged,mergedAt,mergeCommit,headRefName,baseRefName"], 80_000);
}
function readPullRequestFiles(number: number): CommandJsonResult {
@@ -457,7 +747,7 @@ function currentGitopsRevision(): string | null {
return statusText(argo).split(/\r?\n/u)[0] ?? null;
}
function rolloutRecordBody(input: {
export function rolloutRecordBody(input: {
pr: OpenPullRequest;
prData: Record<string, unknown>;
fileSummary: Record<string, unknown>;
@@ -468,15 +758,19 @@ function rolloutRecordBody(input: {
pipelineSucceededAt: string | null;
finishedAt: string | null;
rollout: Record<string, unknown>;
ciMetrics?: CiPipelineMetrics | null;
}): string {
const now = beijingParts();
const title = String(record(input.prData.json).title ?? input.pr.title ?? `PR #${input.pr.number}`);
const prBody = String(record(input.prData.json).body ?? record(input.prData.pullRequest).body ?? "");
const url = String(record(input.prData.json).url ?? input.pr.url ?? `https://github.com/pikasTech/HWLAB/pull/${input.pr.number}`);
const summary = record(input.fileSummary.summary);
const keyFiles = Array.isArray(input.fileSummary.keyFiles) ? input.fileSummary.keyFiles.map(String) : [];
const semanticBullets = semanticChangelogBullets(title, prBody, input.fileSummary);
const mergeToPipeline = durationSeconds(input.mergedAt, input.pipelineSucceededAt);
const pipelineToDev = durationSeconds(input.pipelineSucceededAt, input.finishedAt);
const mergeToDev = durationSeconds(input.mergedAt, input.finishedAt);
const metrics = input.ciMetrics ?? ciMetricsFromValue(input.rollout.ciMetrics);
return [
"",
"",
@@ -492,8 +786,10 @@ function rolloutRecordBody(input: {
` - merge -> pipeline succeeded: ${formatDuration(mergeToPipeline)}`,
` - pipeline succeeded -> DEV Healthy: ${formatDuration(pipelineToDev)}`,
` - merge -> DEV Healthy: ${formatDuration(mergeToDev)}`,
"- 上线 changelog:",
` - ${title}`,
...ciMetricLines(metrics),
"- 上线 changelog(语义化):",
...semanticBullets.map((bullet) => ` - ${bullet}`),
"- 自动 diff 摘要:",
` - changed files: ${String(summary.files ?? "n/a")}; +${String(summary.additions ?? "n/a")} / -${String(summary.deletions ?? "n/a")}; commits: ${String(summary.commits ?? "n/a")}`,
...keyFiles.map((file) => ` - ${file}`),
"- DEV 验证:",
@@ -530,6 +826,7 @@ function appendRolloutBrief(options: G14RecordRolloutOptions, rollout: Record<st
const mergedAt = options.mergedAt ?? prMergedAt;
const pipelineSucceededAt = options.pipelineSucceededAt ?? rolloutPipelineSucceededAt;
const finishedAt = options.finishedAt ?? rolloutFinishedAt;
const ciMetrics = ciMetricsFromValue(rollout.ciMetrics) ?? collectPipelineMetrics(pipelineRun);
const pr: OpenPullRequest = {
number: options.prNumber,
title: String(record(prData.json).title ?? ""),
@@ -537,7 +834,7 @@ function appendRolloutBrief(options: G14RecordRolloutOptions, rollout: Record<st
baseRefName: String(record(prData.json).baseRefName ?? ""),
headRefName: String(record(prData.json).headRefName ?? ""),
};
const body = rolloutRecordBody({ pr, prData, fileSummary: files, sourceCommit, pipelineRun, gitopsRevision, mergedAt, pipelineSucceededAt, finishedAt, rollout });
const body = rolloutRecordBody({ pr, prData, fileSummary: files, sourceCommit, pipelineRun, gitopsRevision, mergedAt, pipelineSucceededAt, finishedAt, rollout, ciMetrics });
if (Number(brief.number) <= 0 || options.dryRun) {
return { ok: true, dryRun: true, date: now.date, brief, wouldAppend: { bodyPreview: body.slice(0, 1000), bodyChars: body.length }, ensured };
}
@@ -550,7 +847,7 @@ function appendRolloutBrief(options: G14RecordRolloutOptions, rollout: Record<st
const bodyPath = writeStateFile(`rollout-pr-${options.prNumber}-${shortSha(sourceCommit)}.md`, `${body}\n`);
const update = cliJson(["gh", "issue", "update", String(brief.number), "--repo", HWLAB_REPO, "--mode", "append", "--body-file", bodyPath, "--body-profile", "commander-brief"], 100_000);
if (!isCommandSuccess(update)) return { ok: false, phase: "append-rollout-brief", update, bodyPath, brief, ensured };
return { ok: true, date: now.date, brief, sourceCommit, pipelineRun, gitopsRevision, bodyPath, update: commandData(update), ensured };
return { ok: true, date: now.date, brief, sourceCommit, pipelineRun, gitopsRevision, ciMetrics, bodyPath, update: commandData(update), ensured };
}
async function waitForG14Dev(sourceCommit: string, timeoutSeconds: number): Promise<Record<string, unknown>> {
@@ -586,7 +883,9 @@ async function waitForG14Dev(sourceCommit: string, timeoutSeconds: number): Prom
healthOk = health.ok && /"status"\s*:\s*"ok"/u.test(health.stdout);
if (synced && healthy && record(workloadsReady).ready === true && healthOk) {
const finishedAt = new Date().toISOString();
return { ok: true, sourceCommit, gitopsRevision, pipelineRun: `hwlab-g14-ci-poll-${shortSha(sourceCommit)}`, pipelineText, argoText, startedAt, pipelineSucceededAt, finishedAt, workloadsReady, healthOk };
const pipelineRun = `hwlab-g14-ci-poll-${shortSha(sourceCommit)}`;
const ciMetrics = collectPipelineMetrics(pipelineRun);
return { ok: true, sourceCommit, gitopsRevision, pipelineRun, pipelineText, argoText, startedAt, pipelineSucceededAt, finishedAt, workloadsReady, healthOk, ciMetrics };
}
await sleep(30_000);
}