feat: enrich hwlab rollout briefs
This commit is contained in:
+306
-7
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user