Publish Claude Code Binaries / Stage and publish binaries (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-arm64 (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build linux-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build windows-amd64 (push) Canceled after 0s
Sync Tutti Agent Skills / sync (push) Canceled after 0s
Windows Agent Adapters / Test Windows agent adapters (push) Canceled after 0s
Windows Daemon Adapters / Test Windows daemon adapters (push) Canceled after 0s
Publish Tutti App Runtime / Publish runtime catalog (push) Canceled after 0s
456 lines
13 KiB
JavaScript
456 lines
13 KiB
JavaScript
#!/usr/bin/env node
|
|
|
|
import { execFileSync } from "node:child_process";
|
|
import { readFile, writeFile } from "node:fs/promises";
|
|
import { pathToFileURL } from "node:url";
|
|
import {
|
|
isPrereleaseVersion,
|
|
normalizeVersion,
|
|
resolvePreviousReleaseTag
|
|
} from "./lib/previousReleaseTag.mjs";
|
|
|
|
const schemaVersion = "tutti.desktop.release.summary.v1";
|
|
const agnesEndpoint = "https://apihub.agnes-ai.com/v1/chat/completions";
|
|
const agnesModel = "agnes-2.0-flash";
|
|
const zhSectionTitles = ["功能变更", "体验优化", "问题修复", "发布与更新"];
|
|
const enSectionTitles = [
|
|
"Feature Updates",
|
|
"Experience Improvements",
|
|
"Bug Fixes",
|
|
"Release and Updates"
|
|
];
|
|
|
|
function parseArgs(argv) {
|
|
const args = new Map();
|
|
for (let index = 2; index < argv.length; index += 1) {
|
|
const key = argv[index];
|
|
if (!key.startsWith("--")) {
|
|
continue;
|
|
}
|
|
const next = argv[index + 1];
|
|
if (!next || next.startsWith("--")) {
|
|
args.set(key.slice(2), "true");
|
|
continue;
|
|
}
|
|
args.set(key.slice(2), next);
|
|
index += 1;
|
|
}
|
|
return args;
|
|
}
|
|
|
|
function readOption(args, name, envName, fallback = "") {
|
|
return (args.get(name) ?? process.env[envName] ?? fallback).trim();
|
|
}
|
|
|
|
function requireOption(value, label) {
|
|
const normalized = String(value ?? "").trim();
|
|
if (!normalized) {
|
|
throw new Error(`${label} is required`);
|
|
}
|
|
return normalized;
|
|
}
|
|
|
|
function resolveChannel({ channel = "", version }) {
|
|
if (channel === "stable" || channel === "rc" || channel === "beta") {
|
|
return channel;
|
|
}
|
|
const prereleaseMatch = /^.+-(rc|beta)\.(0|[1-9]\d*)$/u.exec(version);
|
|
if (prereleaseMatch) {
|
|
return prereleaseMatch[1];
|
|
}
|
|
return isPrereleaseVersion(version) ? "rc" : "stable";
|
|
}
|
|
|
|
function runGit(args, fallback = "") {
|
|
try {
|
|
return execFileSync("git", args, {
|
|
encoding: "utf8",
|
|
stdio: ["ignore", "pipe", "ignore"]
|
|
}).trim();
|
|
} catch {
|
|
return fallback;
|
|
}
|
|
}
|
|
|
|
function listReleaseTags() {
|
|
return runGit(["tag", "--list", "v*", "--sort=-version:refname"])
|
|
.split("\n")
|
|
.map((tag) => tag.trim())
|
|
.filter(Boolean);
|
|
}
|
|
|
|
function resolveGitSha(target) {
|
|
return runGit(["rev-parse", target], target);
|
|
}
|
|
|
|
function collectReleaseInput({ compareFrom, target }) {
|
|
const range = compareFrom ? `${compareFrom}..${target}` : target;
|
|
return {
|
|
commits: runGit(
|
|
[
|
|
"log",
|
|
"--no-merges",
|
|
"--pretty=format:%h %s",
|
|
compareFrom ? range : "-40"
|
|
],
|
|
""
|
|
)
|
|
.split("\n")
|
|
.map((line) => line.trim())
|
|
.filter(Boolean)
|
|
.slice(0, 80),
|
|
diffStat: compareFrom
|
|
? runGit(["diff", "--stat", "--find-renames", range], "")
|
|
: "",
|
|
range
|
|
};
|
|
}
|
|
|
|
function stripConventionalPrefix(message) {
|
|
return message
|
|
.replace(/^[0-9a-f]{7,}\s+/iu, "")
|
|
.replace(
|
|
/^(feat|fix|perf|refactor|docs|test|chore|ci)(\([^)]+\))?!?:\s*/iu,
|
|
""
|
|
)
|
|
.trim();
|
|
}
|
|
|
|
function classifyCommit(message) {
|
|
if (/^[0-9a-f]{7,}\s+fix(\(|:|!)/iu.test(message)) {
|
|
return "问题修复";
|
|
}
|
|
if (/^[0-9a-f]{7,}\s+feat(\(|:|!)/iu.test(message)) {
|
|
return "功能变更";
|
|
}
|
|
if (/^[0-9a-f]{7,}\s+(perf|refactor)(\(|:|!)/iu.test(message)) {
|
|
return "体验优化";
|
|
}
|
|
if (
|
|
/release|download|updat|installer|dmg|s3|github action|workflow/iu.test(
|
|
message
|
|
)
|
|
) {
|
|
return "发布与更新";
|
|
}
|
|
return "体验优化";
|
|
}
|
|
|
|
function toSentence(value) {
|
|
const trimmed = value.trim().replace(/[。.]$/u, "");
|
|
return trimmed ? `${trimmed}。` : "";
|
|
}
|
|
|
|
function buildFallbackSections(commits) {
|
|
const grouped = new Map();
|
|
for (const commit of commits) {
|
|
const title = classifyCommit(commit);
|
|
const item = toSentence(stripConventionalPrefix(commit));
|
|
if (!item) {
|
|
continue;
|
|
}
|
|
const items = grouped.get(title) ?? [];
|
|
if (items.length < 4 && !items.includes(item)) {
|
|
items.push(item);
|
|
}
|
|
grouped.set(title, items);
|
|
}
|
|
|
|
const sections = [...grouped.entries()]
|
|
.map(([title, items]) => ({ title, items }))
|
|
.filter((section) => section.items.length > 0)
|
|
.slice(0, 4);
|
|
|
|
return sections.length > 0
|
|
? sections
|
|
: [{ title: "功能优化", items: ["整理本次桌面端发布内容。"] }];
|
|
}
|
|
|
|
function localizeSectionsToEnglish(sections) {
|
|
const titleMap = new Map([
|
|
["功能变更", "Feature Updates"],
|
|
["体验优化", "Experience Improvements"],
|
|
["问题修复", "Bug Fixes"],
|
|
["发布与更新", "Release and Updates"]
|
|
]);
|
|
return sections.map((section) => ({
|
|
title: titleMap.get(section.title) ?? section.title,
|
|
items: section.items.map((item) =>
|
|
item
|
|
.replace(/。$/u, ".")
|
|
.replace(/^整理本次桌面端发布内容/u, "Summarized this desktop release")
|
|
)
|
|
}));
|
|
}
|
|
|
|
function normalizeSectionTitle(title, language) {
|
|
const value = String(title ?? "").trim();
|
|
if (language === "en") {
|
|
if (enSectionTitles.includes(value)) {
|
|
return value;
|
|
}
|
|
if (/bug|fix|issue|crash|error|correct/i.test(value)) {
|
|
return "Bug Fixes";
|
|
}
|
|
if (
|
|
/release|update|download|install|installer|channel|stable|beta/i.test(
|
|
value
|
|
) ||
|
|
/\brc\b/i.test(value)
|
|
) {
|
|
return "Release and Updates";
|
|
}
|
|
if (/feature|new|support|add/i.test(value)) {
|
|
return "Feature Updates";
|
|
}
|
|
return "Experience Improvements";
|
|
}
|
|
|
|
if (zhSectionTitles.includes(value)) {
|
|
return value;
|
|
}
|
|
if (/修复|问题|错误|崩溃|异常|bug/i.test(value)) {
|
|
return "问题修复";
|
|
}
|
|
if (/发布|更新|下载|安装|渠道|稳定版|正式版|预发布|RC|Beta/i.test(value)) {
|
|
return "发布与更新";
|
|
}
|
|
if (/新增|新功能|支持|能力|功能/i.test(value)) {
|
|
return "功能变更";
|
|
}
|
|
return "体验优化";
|
|
}
|
|
|
|
function buildFallbackSummary(input) {
|
|
const sections = buildFallbackSections(input.commits);
|
|
return {
|
|
source: "fallback",
|
|
zh: {
|
|
headline: "本次版本整理了桌面端更新内容。",
|
|
sections,
|
|
qaFocus: ["验证安装包下载、自动更新检查和主要桌面端启动流程。"]
|
|
},
|
|
en: {
|
|
headline: "This release summarizes the latest desktop changes.",
|
|
sections: localizeSectionsToEnglish(sections),
|
|
qaFocus: [
|
|
"Verify installer download, update checks, and the main desktop startup flow."
|
|
]
|
|
}
|
|
};
|
|
}
|
|
|
|
function sanitizeSummary(candidate) {
|
|
const fallback = buildFallbackSummary({ commits: [] });
|
|
const normalizeLocale = (locale, fallbackLocale, language) => ({
|
|
headline:
|
|
typeof locale?.headline === "string" && locale.headline.trim()
|
|
? locale.headline.trim()
|
|
: fallbackLocale.headline,
|
|
sections: Array.isArray(locale?.sections)
|
|
? locale.sections
|
|
.map((section) => ({
|
|
title: normalizeSectionTitle(section?.title, language),
|
|
items: Array.isArray(section?.items)
|
|
? section.items
|
|
.filter((item) => typeof item === "string" && item.trim())
|
|
.map((item) => item.trim())
|
|
.slice(0, 4)
|
|
: []
|
|
}))
|
|
.filter((section) => section.items.length > 0)
|
|
.slice(0, 4)
|
|
: fallbackLocale.sections,
|
|
qaFocus: Array.isArray(locale?.qaFocus)
|
|
? locale.qaFocus
|
|
.filter((item) => typeof item === "string" && item.trim())
|
|
.map((item) => item.trim())
|
|
.slice(0, 3)
|
|
: fallbackLocale.qaFocus
|
|
});
|
|
|
|
return {
|
|
source: candidate?.source === "agnes" ? "agnes" : "fallback",
|
|
zh: normalizeLocale(candidate?.zh, fallback.zh, "zh"),
|
|
en: normalizeLocale(candidate?.en, fallback.en, "en")
|
|
};
|
|
}
|
|
|
|
function extractJsonObject(value) {
|
|
const start = value.indexOf("{");
|
|
const end = value.lastIndexOf("}");
|
|
if (start === -1 || end === -1 || end < start) {
|
|
return null;
|
|
}
|
|
try {
|
|
return JSON.parse(value.slice(start, end + 1));
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async function buildAgnesSummary(input, apiKey) {
|
|
const prompt = [
|
|
"You summarize desktop app releases for end users and QA, not for engineers.",
|
|
"Return only JSON with shape:",
|
|
'{"zh":{"headline":"","sections":[{"title":"","items":[""]}],"qaFocus":[""]},"en":{"headline":"","sections":[{"title":"","items":[""]}],"qaFocus":[""]}}',
|
|
"Use Chinese for zh and English for en. Do not invent changes beyond commits and diff stat.",
|
|
"Write from user-visible impact first. Avoid implementation jargon unless it directly affects installation, updates, data safety, or QA.",
|
|
"When a change is release infrastructure only, describe it as internal QA, packaging, download, or update validation work; do not claim regular users can access it unless the commits explicitly add user-facing UI or settings.",
|
|
"For beta channel changes, say internal beta packages or development-branch validation. Do not describe beta as public early access unless the commits explicitly say it is exposed to users.",
|
|
`For zh.sections[].title, use only these section titles: ${zhSectionTitles.join("、")}.`,
|
|
`For en.sections[].title, use only these section titles: ${enSectionTitles.join(", ")}.`,
|
|
"Do not use module or architecture headings such as Core Architecture, Backend, Database, IPC, AgentGUI, or Services.",
|
|
"Translate internal refactors into plain outcomes, for example more reliable startup, clearer settings, smoother agent conversations, safer update flow, or better data migration.",
|
|
"Keep each item short and concrete. Prefer 3 to 6 useful user-facing items total. Put technical-only changes into Release and Updates or omit them if they have no user/QA impact.",
|
|
"",
|
|
`Tag: ${input.tag}`,
|
|
`Version: ${input.version}`,
|
|
`Channel: ${input.channel}`,
|
|
`Range: ${input.compare.range}`,
|
|
"",
|
|
"Commits:",
|
|
input.commits.join("\n") || "(none)",
|
|
"",
|
|
"Diff stat:",
|
|
input.diffStat || "(none)"
|
|
].join("\n");
|
|
|
|
const response = await fetch(agnesEndpoint, {
|
|
body: JSON.stringify({
|
|
messages: [{ role: "user", content: prompt }],
|
|
model: agnesModel,
|
|
temperature: 0.2
|
|
}),
|
|
headers: {
|
|
authorization: `Bearer ${apiKey}`,
|
|
"content-type": "application/json"
|
|
},
|
|
method: "POST"
|
|
});
|
|
if (!response.ok) {
|
|
throw new Error(`Agnes returned ${response.status}`);
|
|
}
|
|
const data = await response.json();
|
|
const content = data?.choices?.[0]?.message?.content;
|
|
const parsed =
|
|
typeof content === "string" ? extractJsonObject(content) : null;
|
|
if (!parsed) {
|
|
throw new Error("Agnes response did not contain JSON");
|
|
}
|
|
return sanitizeSummary({ ...parsed, source: "agnes" });
|
|
}
|
|
|
|
async function buildReleaseSummary(options) {
|
|
const tag = requireOption(options.tag, "tag");
|
|
const version = normalizeVersion(tag);
|
|
const channel = resolveChannel({ channel: options.channel, version });
|
|
const target = requireOption(options.target || "HEAD", "target");
|
|
const targetCommit = resolveGitSha(target);
|
|
const requestedCompareFrom = String(options.compareFrom ?? "").trim();
|
|
const compareFrom =
|
|
requestedCompareFrom ||
|
|
resolvePreviousReleaseTag({
|
|
channel,
|
|
tag,
|
|
tags: listReleaseTags(),
|
|
version
|
|
});
|
|
const collected = collectReleaseInput({ compareFrom, target });
|
|
const baseInput = {
|
|
channel,
|
|
commits: collected.commits,
|
|
compare: {
|
|
from: compareFrom || null,
|
|
range: collected.range,
|
|
to: target
|
|
},
|
|
diffStat: collected.diffStat,
|
|
tag,
|
|
targetCommit,
|
|
version
|
|
};
|
|
|
|
let content = buildFallbackSummary(collected);
|
|
if (options.apiKey) {
|
|
try {
|
|
content = await buildAgnesSummary(baseInput, options.apiKey);
|
|
} catch (error) {
|
|
content = {
|
|
...content,
|
|
source: "fallback",
|
|
warning: error instanceof Error ? error.message : String(error)
|
|
};
|
|
}
|
|
}
|
|
|
|
return {
|
|
schemaVersion,
|
|
tag,
|
|
version,
|
|
channel,
|
|
prerelease: channel !== "stable",
|
|
targetCommit,
|
|
compare: baseInput.compare,
|
|
generatedAt: new Date().toISOString(),
|
|
summarySource: content.source,
|
|
warning: content.warning,
|
|
zh: content.zh,
|
|
en: content.en
|
|
};
|
|
}
|
|
|
|
async function main() {
|
|
const args = parseArgs(process.argv);
|
|
const outputPath = requireOption(
|
|
readOption(args, "output", "SUMMARY_OUTPUT"),
|
|
"output"
|
|
);
|
|
const inputPath = readOption(args, "input", "SUMMARY_INPUT");
|
|
const input = inputPath ? JSON.parse(await readFile(inputPath, "utf8")) : {};
|
|
const summary = await buildReleaseSummary({
|
|
...input,
|
|
apiKey: readOption(args, "api-key", "AGNES_API_KEY"),
|
|
channel: readOption(
|
|
args,
|
|
"channel",
|
|
"RELEASE_CHANNEL",
|
|
input.channel ?? ""
|
|
),
|
|
compareFrom: readOption(
|
|
args,
|
|
"compare-from",
|
|
"RELEASE_COMPARE_FROM",
|
|
input.compareFrom ?? ""
|
|
),
|
|
tag: readOption(args, "tag", "RELEASE_TAG", input.tag ?? ""),
|
|
target: readOption(args, "target", "RELEASE_TARGET", input.target ?? "HEAD")
|
|
});
|
|
|
|
await writeFile(outputPath, `${JSON.stringify(summary, null, 2)}\n`, "utf8");
|
|
}
|
|
|
|
if (
|
|
process.argv[1] &&
|
|
import.meta.url === pathToFileURL(process.argv[1]).href
|
|
) {
|
|
void main().catch((error) => {
|
|
console.error(error instanceof Error ? error.message : String(error));
|
|
process.exit(1);
|
|
});
|
|
}
|
|
|
|
export {
|
|
buildFallbackReleaseSummary,
|
|
buildReleaseSummary,
|
|
classifyCommit,
|
|
normalizeVersion,
|
|
normalizeSectionTitle,
|
|
resolveChannel,
|
|
schemaVersion
|
|
};
|
|
|
|
function buildFallbackReleaseSummary(input) {
|
|
return buildFallbackSummary(input);
|
|
}
|