diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 5305485..5bcdc21 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -64,3 +64,6 @@ jobs: - name: Run build run: pnpm run build + + - name: Verify build output + run: git diff --exit-code -- dist/index.mjs diff --git a/README.md b/README.md index f0a863f..ec48fee 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # FlowPilot -FlowPilot 是用于 monorepo 发布流程的 GitHub Action。它从 PR 描述中收集 Changelog,将日志暂存到对应包,在 release PR 中生成发布日志,并在 release PR 合并后发布 Node 包或创建 GitHub Release/tag。 +FlowPilot 是用于 monorepo 和单仓发布流程的 GitHub Action。它从 PR 描述中收集 Changelog,在 release PR 中生成发布日志,并在 release PR 合并后发布 Node 包或创建 GitHub Release/tag。 FlowPilot 支持包含 `package.json` 的 Node 包和包含 `pubspec.yaml` 的 Flutter 包。版本更新、`release/*` 分支及 release PR 需要由 Changesets、自有脚本或其他发布工具创建,FlowPilot 不负责修改包版本或创建 release PR。 @@ -10,7 +10,7 @@ FlowPilot 支持包含 `package.json` 的 Node 包和包含 `pubspec.yaml` 的 F - 通过 `/changelog` 指令、Review 通过或编辑确认评论提交日志。 - 按包生成 `.changelog/pr-.md` 暂存文件,并补充贡献者和 PR 链接。 - 在 release PR 中按类型和 scope 汇总日志,生成中英文 Changelog 确认评论。 -- release PR 合并后发布 Node 包,并按规则创建 GitHub Release 及 `${name}@${version}` tag。 +- release PR 合并后发布 Node 包,并按仓库模式创建 `${name}@${version}` 或纯版本号 tag。 - 识别同一仓库中的 Node 和 Flutter 包。 ## 接入示例 @@ -157,10 +157,15 @@ jobs: | 参数 | 是否必需 | 说明 | | --- | --- | --- | | `token` | 按流程 | GitHub API、Git clone/push 和创建 Release 使用的 token。仅生成普通 PR 的 `changelog` output 时可为空,完整流程需要有效 token。 | -| `packages` | Changelog 流程必需 | 参与日志提取的包名。支持逗号或多行输入,名称必须与 manifest 中的 `name` 完全一致。release 检测未配置时会使用发现的全部包。 | +| `packages` | monorepo Changelog 流程必需 | 参与日志提取的包名。支持逗号或多行输入,名称必须与 manifest 中的 `name` 完全一致。release 检测未配置时会使用发现的全部包;`single` 模式无需配置。 | | `pr_number` | `workflow_run` 必需 | `workflow_run` 无法从事件直接获得 PR 编号时使用,其他事件不需要。 | | `translate-secret-id` | 否 | 腾讯混元翻译 SecretId;与 `translate-secret-key` 同时配置后生成英文 release 日志评论。 | | `translate-secret-key` | 否 | 腾讯混元翻译 SecretKey。 | +| `mode` | 否 | 仓库模式:`single`(单仓) 或 `monorepo`(monorepo)。默认 `monorepo`。`single` 模式下不依赖 `.changelog/*.md` 暂存文件,直接从 tag 区间已合并 PR 的 body 生成发布日志,且使用纯版本号 git tag(如 `1.2.3`)。 | +| `package-json-path` | 否 | `single` 模式下指定 `package.json` 的相对路径,默认读取仓库根目录的 `package.json`。仅 `single` 模式生效。 | +| `changelog-path` | 否 | `single` 模式下指定 `CHANGELOG.md` 的相对路径,默认在包目录下读写 `CHANGELOG.md` / `CHANGELOG.en-US.md`。仅 `single` 模式生效。 | +| `from-tag` | 否 | 覆盖日志区间起始 tag。单仓预发布默认取目标 ref 可达的最近 tag,稳定版默认取最近稳定 tag;目标历史无 tag 时扫描全部历史。 | +| `to-tag` | 否 | 覆盖日志区间结束 ref。默认取 release PR 的 base 分支。 | | Output | 说明 | | --- | --- | @@ -274,6 +279,42 @@ release PR 打开时,FlowPilot 读取各包的 `.changelog/*.md`,按类型 | `docs`、`doc` | 📝 Documentation | | 其他类型 | 🚧 Others | +## 基于 tag 的发布日志(单仓) + +单仓场景下,各 PR 的更新日志直接写在 PR 描述的 `### 📝 更新日志` 下(扁平列表,无 `#### package` 分段): + +```md +### 📝 更新日志 + +- fix(aa): aa +``` + +设置 `mode: single` 后,release PR 打开时 FlowPilot 会: + +1. 预发布版本取目标 ref 可达的最近 tag,稳定版取最近的非 alpha/beta tag;`from-tag` 可覆盖起点,目标历史无 tag 时扫描全部历史。release PR 的 base 分支(或 `to-tag`)作为终点。 +2. 分页获取区间内全部 commit,并关联出对应的已合并 PR 编号(去重),兼容 merge、squash 和 rebase 合并。 +3. 逐个拉取 PR body,复用与普通 PR 相同的跳过规则(Bot / `skip-changelog` 标签 / release 分支 / 手动勾选),从 `### 📝 更新日志` 抓取日志。 +4. 拼接贡献者与 PR 链接,按类型分组渲染,生成与暂存模式完全一致的 `# 🎉 发布` / `# 🎉 Release` 确认评论;下游确认与 Release 创建流程不变。 + +PR body 没有有效的 `type(scope): message` 日志且未显式跳过时,FlowPilot 会使用 PR 标题作为回退。符合该格式的标题会保留类型与 scope;其他标题自动归入 `Others`。模板中未勾选的“不需要纳入 Changelog”选项不视为有效日志。 + +单仓模式下,release PR 合并后的 GitHub tag 使用纯版本号(如 `1.2.3`)而非 `${name}@${version}`。alpha/beta 会创建 GitHub prerelease,并生成相对最近 tag 的增量日志;稳定版会汇总最近稳定 tag 之后的完整日志。 + +可通过 `package-json-path` 指定非根目录的 `package.json`,通过 `changelog-path` 指定自定义的 `CHANGELOG.md` 读写位置。 + +所有触发 FlowPilot 的 workflow(release PR 打开、确认评论、release PR 关闭)必须传入相同的单仓配置,例如: + +```yaml +- uses: TDesignOteam/flow-pilot-action@develop + with: + token: ${{ secrets.TDESIGN_BOT_TOKEN }} + mode: single + package-json-path: package.json + changelog-path: CHANGELOG.md +``` + +扫描单个 commit 或 PR 失败时会产生 GitHub Actions warning 并继续,因此应检查 warning 以确认日志是否完整。仓库没有任何 tag 时会分页扫描目标分支的全部提交,历史较长的仓库可通过 `from-tag` 限定首次纳入日志的范围。 + ## Release 流程 ### 1. 创建 release PR @@ -289,7 +330,7 @@ FlowPilot 根据 GitHub API 返回的 `package.json` 或 `pubspec.yaml` patch ### 2. 确认 release 日志 -release PR 打开后,FlowPilot 为 `latest` 版本生成以 `# 🎉 发布` 开头的中文确认评论。配置两个翻译参数后,还会生成以 `# 🎉 Release` 开头的英文评论。 +release PR 打开后,FlowPilot 为 monorepo 的 `latest` 版本以及单仓的所有版本生成以 `# 🎉 发布` 开头的中文确认评论。配置两个翻译参数后,还会生成以 `# 🎉 Release` 开头的英文评论。 检查评论内容并删除第一行提示后,FlowPilot 会: @@ -301,7 +342,7 @@ release PR 打开后,FlowPilot 为 `latest` 版本生成以 `# 🎉 发布` ### 3. 合并并发布 -release PR 合并后,各类包的处理方式如下: +release PR 合并后,monorepo 各类包的处理方式如下: | 包类型 | Registry 发布 | GitHub Release/tag | | --- | --- | --- | @@ -312,9 +353,11 @@ release PR 合并后,各类包的处理方式如下: GitHub Release 标题和 tag 均为 `${name}@${version}`。Flutter 包可使用该 tag 触发独立的 OIDC 发布工作流。 +`single` 模式下,所有版本均尝试创建纯版本号 GitHub Release/tag;alpha/beta 标记为 prerelease。Node registry 发布和私有包跳过规则保持不变。单仓 Release/tag 创建失败会使 workflow 失败,以避免后续版本缺少日志起始 tag。 + Node 发布固定使用 pnpm 的 `--filter`,因此 Node monorepo 必须配置 pnpm workspace,并在运行 FlowPilot 前安装 pnpm;仅包含 Flutter 包时不需要 pnpm。 -Registry 发布失败会使 workflow 失败;创建 GitHub Release/tag 失败时,FlowPilot 当前只记录日志并继续处理其他包,应结合 Action 日志或后续检查确认 Release 已创建。 +Registry 发布失败会使 workflow 失败;monorepo 创建 GitHub Release/tag 失败时只记录日志并继续处理其他包,单仓模式则会使 workflow 失败。 release PR 合并发布必须使用 `pull_request: closed`。FlowPilot 不支持 `pull_request_target`,避免由该事件创建的 Release/tag 导致后续 OIDC 发布被拒绝。 diff --git a/action.yml b/action.yml index 95a7517..3de3d05 100644 --- a/action.yml +++ b/action.yml @@ -14,6 +14,22 @@ inputs: translate-secret-key: description: translate secret key required: false + mode: + description: 仓库模式;`single` 为单仓(自动从 tag 区间 PR 抓取日志,使用纯版本号 git tag);`monorepo` 为 monorepo(glob 自动发现包,使用 `.changelog/` 暂存)。默认 `monorepo` + required: false + default: monorepo + package-json-path: + description: 单仓模式下指定 package.json 路径(相对于仓库根目录),默认读取根目录 package.json + required: false + changelog-path: + description: 单仓模式下指定 CHANGELOG.md 路径,默认在包目录下读写 CHANGELOG.md / CHANGELOG.en-US.md + required: false + from-tag: + description: 覆盖日志区间起始 tag(默认选择目标 ref 可达的最近 tag;无 tag 时扫描全部历史) + required: false + to-tag: + description: 覆盖日志区间结束 ref(默认取 release PR 的 base 分支) + required: false outputs: changelog: diff --git a/dist/index.mjs b/dist/index.mjs index 9371d42..0506a0e 100644 --- a/dist/index.mjs +++ b/dist/index.mjs @@ -12,10 +12,10 @@ import { StringDecoder } from "string_decoder"; import * as child from "child_process"; import { setTimeout as setTimeout$1 } from "timers"; import { existsSync as existsSync$1, mkdirSync, readFileSync as readFileSync$1, unlinkSync, writeFileSync } from "node:fs"; +import { basename as basename$1, dirname as dirname$1, relative as relative$1, resolve as resolve$1, sep as sep$1 } from "node:path"; import { cwd } from "node:process"; import { fileURLToPath } from "url"; import { createRequire as createRequire$1 } from "module"; -import { basename as basename$1, dirname as dirname$1, relative as relative$1, resolve as resolve$1, sep as sep$1 } from "node:path"; //#region \0rolldown/runtime.js var __create = Object.create; var __defProp = Object.defineProperty; @@ -30865,8 +30865,191 @@ function getPackages(path) { }; }).sort((a, b) => a.relativeDir.localeCompare(b.relativeDir) || a.type.localeCompare(b.type)); } +function getSinglePackage(rootDir, manifestPath) { + const absolutePath = resolve$1(rootDir, manifestPath); + const manifest = parseManifest(absolutePath); + if (!manifest || typeof manifest !== "object" || !("name" in manifest) || typeof manifest.name !== "string" || !manifest.name.trim()) throw new Error(`Package manifest "${absolutePath}" is missing a valid "name" field`); + const dir = dirname$1(absolutePath); + const relativeDir = relative$1(rootDir, dir).split(sep$1).join("/") || "."; + const type = basename$1(absolutePath) === "package.json" ? "node" : "flutter"; + return { + name: manifest.name, + version: typeof manifest.version === "string" ? manifest.version : void 0, + type, + private: type === "node" ? manifest.private === true : manifest.publish_to === "none", + dependencies: type === "node" ? getNodeDependencies(manifest) : [], + dir, + relativeDir + }; +} +//#endregion +//#region src/utils/github.ts +function useGithub(token) { + const octokit = getOctokit(token); + const { repo, owner } = context.repo; + async function getPullRequestData(pr_number) { + const { data } = await octokit.rest.pulls.get({ + owner, + repo, + pull_number: pr_number + }); + return data; + } + async function getPullRequestFiles(pr_number) { + return octokit.paginate(octokit.rest.pulls.listFiles, { + owner, + repo, + pull_number: pr_number, + per_page: 100 + }); + } + async function getOpenPullRequestByHead(head) { + const { data } = await octokit.rest.pulls.list({ + owner, + repo, + head: `${owner}:${head}`, + state: "open" + }); + return data[0]; + } + async function createPullRequest(title, head, base, body) { + const { data } = await octokit.rest.pulls.create({ + owner, + repo, + title, + head, + base, + body + }); + return data; + } + async function getCommentList(pr_number) { + const { data } = await octokit.rest.issues.listComments({ + owner, + repo, + issue_number: pr_number + }); + return data; + } + async function addComment(pr_number, body) { + await octokit.rest.issues.createComment({ + owner, + repo, + issue_number: pr_number, + body + }); + } + async function updateComment(comment_id, body) { + await octokit.rest.issues.updateComment({ + owner, + repo, + comment_id, + body + }); + } + async function addPullRequestLabels(pr_number, labels) { + await octokit.rest.issues.addLabels({ + owner, + repo, + issue_number: pr_number, + labels + }); + } + async function getRequestedReviewers(pr_number) { + const { data } = await octokit.rest.pulls.listRequestedReviewers({ + owner, + repo, + pull_number: pr_number + }); + return data.users.map((item) => item.login); + } + async function createRelease(tag_name, name, body, target_commitish, prerelease = false) { + await octokit.rest.repos.createRelease({ + owner, + repo, + tag_name, + name, + body, + target_commitish, + prerelease + }); + } + async function getCommitsBetweenRefs(base, head) { + if (!base) return (await octokit.paginate(octokit.rest.repos.listCommits, { + owner, + repo, + sha: head, + per_page: 100 + })).reverse(); + return octokit.paginate(octokit.rest.repos.compareCommitsWithBasehead, { + owner, + repo, + basehead: `${base}...${head}`, + per_page: 100 + }, (response) => { + const data = response.data; + return Array.isArray(data) ? data : data.commits; + }); + } + /** + * 获取 base..head 之间已合并 PR 的编号列表(去重)。 + * base 为空时扫描 head 的全部历史。单个提交查询失败时告警并继续。 + */ + async function getMergedPrNumbersBetweenRefs(base, head) { + const commits = await getCommitsBetweenRefs(base, head); + const prNumbers = /* @__PURE__ */ new Set(); + let failedCommits = 0; + for (const commit of commits) try { + (await octokit.paginate(octokit.rest.repos.listPullRequestsAssociatedWithCommit, { + owner, + repo, + commit_sha: commit.sha, + per_page: 100 + })).forEach((pr) => { + if (pr.number && pr.merged_at) prNumbers.add(pr.number); + }); + } catch (error) { + failedCommits++; + warning(`getMergedPrNumbersBetweenRefs: 跳过 commit ${commit.sha}: ${error instanceof Error ? error.message : String(error)}`); + } + info(`getMergedPrNumbersBetweenRefs: 扫描 ${commits.length} 个 commit,关联 ${prNumbers.size} 个 PR`); + if (failedCommits) warning(`getMergedPrNumbersBetweenRefs: ${failedCommits} 个 commit 查询失败,发布日志可能不完整`); + return [...prNumbers]; + } + return { + getPullRequestData, + getPullRequestFiles, + getOpenPullRequestByHead, + createPullRequest, + addPullRequestLabels, + addComment, + updateComment, + getCommentList, + getRequestedReviewers, + createRelease, + getMergedPrNumbersBetweenRefs + }; +} //#endregion //#region src/utils/common.ts +function getMode() { + return getInput("mode", { trimWhitespace: true }) === "single" ? "single" : "monorepo"; +} +function isSingleMode() { + return getMode() === "single"; +} +function getEnglishChangelogPath(path) { + return path.endsWith(".md") ? path.replace(/\.md$/, ".en-US.md") : `${path}.en-US.md`; +} +function getChangelogFilePath(release, lang) { + const customPath = getInput("changelog-path", { trimWhitespace: true }); + if (customPath) { + if (lang === "en") return getEnglishChangelogPath(customPath); + return customPath; + } + const fileName = lang === "en" ? "CHANGELOG.en-US.md" : "CHANGELOG.md"; + return `${release.dir}/${fileName}`; +} const USE_PASCAL_CASE_REG = /^Use(?=[A-Z])/; const RN_TO_LF_REG = /\r\n/g; const COMMON_PR_REG = /\[common#\d+\]/; @@ -31024,8 +31207,14 @@ function readPackageManifest(path, type) { function getPullRequestReleaseDirs(prFiles, packages) { const zhChangelogs = {}; const enChangelogs = {}; + const customChangelogPath = isSingleMode() ? getInput("changelog-path", { trimWhitespace: true }).replace(/^\.\//, "") : ""; + const customEnChangelogPath = customChangelogPath ? getEnglishChangelogPath(customChangelogPath) : ""; + const singleChangelogKey = "__single__"; return prFiles.filter((file) => { - if (file.filename.includes("CHANGELOG.md") && file.patch) { + const changelogKey = customChangelogPath ? singleChangelogKey : dirname$1(file.filename); + const isZhChangelog = customChangelogPath ? file.filename === customChangelogPath : file.filename.includes("CHANGELOG.md"); + const isEnChangelog = customEnChangelogPath ? file.filename === customEnChangelogPath : file.filename.includes("CHANGELOG.en-US.md"); + if (isZhChangelog && file.patch) { const logs = []; let isSkip = false; let hasNewReleaseLog = false; @@ -31041,9 +31230,9 @@ function getPullRequestReleaseDirs(prFiles, packages) { logs.push(log.trimEnd()); } }); - zhChangelogs[dirname$1(file.filename)] = logs.join("\n"); + zhChangelogs[changelogKey] = logs.join("\n"); } - if (file.filename.includes("CHANGELOG.en-US.md") && file.patch) { + if (isEnChangelog && file.patch) { const logs = []; let isSkip = false; let hasNewReleaseLog = false; @@ -31059,7 +31248,7 @@ function getPullRequestReleaseDirs(prFiles, packages) { logs.push(log.trimEnd()); } }); - enChangelogs[dirname$1(file.filename)] = logs.slice(1).join("\n"); + enChangelogs[changelogKey] = logs.slice(1).join("\n"); } if (file.status !== "modified") return false; const type = getManifestType(file.filename); @@ -31074,24 +31263,26 @@ function getPullRequestReleaseDirs(prFiles, packages) { }).map((file) => { const type = getManifestType(file.filename); const packageData = readPackageManifest(file.filename, type); - const version = getChangedVersions(file.patch || "", type).newVersion; + const { newVersion: version, oldVersion } = getChangedVersions(file.patch || "", type); if (typeof packageData.name !== "string") throw new Error(`Package manifest "${file.filename}" is missing a valid "name" field`); if (typeof packageData.version !== "string" && typeof packageData.version !== "number") throw new Error(`Package manifest "${file.filename}" is missing a valid "version" field`); if (String(packageData.version) !== version) throw new Error(`Package manifest "${file.filename}" has version "${packageData.version}", expected "${version}" from the pull request diff`); let tag = "latest"; if (version.includes("beta")) tag = "beta"; if (version.includes("alpha")) tag = "alpha"; - let changelog = zhChangelogs[dirname$1(file.filename)] || ""; - if (changelog && enChangelogs[dirname$1(file.filename)]) changelog = [ + const changelogKey = customChangelogPath ? singleChangelogKey : dirname$1(file.filename); + let changelog = zhChangelogs[changelogKey] || ""; + if (changelog && enChangelogs[changelogKey]) changelog = [ ...changelog, "\n---\n", - enChangelogs[dirname$1(file.filename)] + enChangelogs[changelogKey] ].join(""); return { dir: dirname$1(file.filename), name: packageData.name, private: type === "node" ? packageData.private === true : packageData.publish_to === "none", version, + oldVersion, type, tag, changelog @@ -31159,6 +31350,86 @@ function renderChangelogMarkdown(changelogs) { renderChangelog("### 🚧 Others", otherList) ].filter((n) => n).join("\n"); } +/** +* 从 PR body 提取「基于 tag 区间」的发布日志(单仓扁平格式): +* 直接贴在 `### 📝 更新日志` 标题下的列表(无 `#### package` 分段), +* 以及 `#### all` / `#### ` 分段。返回纯日志条目(不含 `- ` 前缀)。 +*/ +function extractTagChangelogLogs(markdown, pkgNames) { + const md = parseMarkdown(markdown); + const changelogHeading = getChangelogHeading(); + const pkgDepth = changelogHeading.depth + 1; + let collectLogs = false; + let pkgName = ""; + const logs = []; + md.forEach((token) => { + if (token.type === changelogHeading.type && token.depth === changelogHeading.depth) { + collectLogs = token.text === changelogHeading.text; + pkgName = ""; + return; + } + if (!collectLogs) return; + if (token.type === "heading") { + if (token.depth === pkgDepth) pkgName = token.text; + else collectLogs = false; + return; + } + if (token.type === "list") { + const items = token.items; + if (pkgName === "all" || pkgNames.includes(pkgName) || pkgName === "") { + const targetCount = pkgName === "all" ? pkgNames.length : 1; + items.forEach((item) => { + if (item.type === "list_item" && item.tokens.length) { + const text = item.tokens[0].text; + for (let i = 0; i < targetCount; i++) logs.push(text); + } + }); + } + } + }); + return logs; +} +function isRenderableChangelogLog(log) { + return CHANGELOG_REG.test(`- ${log}`); +} +function getPullRequestTitleLog(title) { + const normalizedTitle = title.trim(); + if (!normalizedTitle) return void 0; + return isRenderableChangelogLog(normalizedTitle) ? normalizedTitle : `other: ${normalizedTitle}`; +} +/** +* 基于两个 tag(或 ref)之间已合并 PR 的 body 生成发布日志(单仓)。 +* fromRef 为空时扫描 toRef 的全部历史。 +*/ +async function getTagChangelog(token, pkgNames, fromRef, toRef) { + const { getMergedPrNumbersBetweenRefs, getPullRequestData } = useGithub(token); + const prNumbers = await getMergedPrNumbersBetweenRefs(fromRef, toRef); + const logs = []; + let failedPullRequests = 0; + for (const prNumber of prNumbers) try { + const prData = await getPullRequestData(prNumber); + if (!isExtractPRLog(prData)) continue; + const prLogs = extractTagChangelogLogs(prData.body || "", pkgNames).filter(isRenderableChangelogLog); + if (!prLogs.length) { + const titleLog = getPullRequestTitleLog(prData.title); + if (titleLog) { + prLogs.push(titleLog); + info(`getTagChangelog: PR #${prNumber} 未提供有效更新日志,使用 PR 标题`); + } + } + prLogs.forEach((log) => { + const contributor = prData.user.login === "tdesign-bot" || CONTRIBUTOR_WITH_SPACE_REG.test(log) ? "" : ` @${prData.user.login}`; + const prLink = COMMON_PR_REG.test(log) ? "" : ` ([#${prNumber}](${prData.html_url}))`; + logs.push(`- ${log}${contributor}${prLink}`); + }); + } catch (error) { + failedPullRequests++; + warning(`getTagChangelog: 跳过 PR #${prNumber}: ${error instanceof Error ? error.message : String(error)}`); + } + info(`getTagChangelog: 扫描 ${prNumbers.length} 个 PR,生成 ${logs.length} 条日志`); + if (failedPullRequests) warning(`getTagChangelog: ${failedPullRequests} 个 PR 查询失败,发布日志可能不完整`); + return renderChangelogMarkdown(logs); +} function renderChangelog(heading, changelogs) { let content = ""; const keys = Object.keys(changelogs).sort(); @@ -31191,6 +31462,7 @@ function getInputPkgs() { return getMultilineInput("packages", { trimWhitespace: true }).flatMap((line) => line.split(",")).map((pkg) => pkg.trim()).filter(Boolean); } function getConfiguredPackages(path) { + if (isSingleMode()) return [getSinglePackage(path, getInput("package-json-path", { trimWhitespace: true }) || resolve$1(path, "package.json"))]; const packageNames = getInputPkgs(); const packages = getPackages(path); return packageNames.length ? packages.filter((pkg) => packageNames.includes(pkg.name)) : packages; @@ -31283,6 +31555,18 @@ function useGit(token) { ]); await exec("git", ["fetch", origin]); } + async function getLatestTag(ref, stableOnly = false) { + const args = [ + "describe", + "--tags", + "--abbrev=0" + ]; + if (stableOnly) args.push("--exclude", "*alpha*", "--exclude", "*Alpha*", "--exclude", "*ALPHA*", "--exclude", "*beta*", "--exclude", "*Beta*", "--exclude", "*BETA*"); + const describe = (target) => getExecOutput("git", [...args, target], { ignoreReturnCode: true }); + let result = await describe(ref); + if (result.exitCode !== 0 && !ref.startsWith("origin/")) result = await describe(`origin/${ref}`); + return result.exitCode === 0 ? result.stdout.trim() || void 0 : void 0; + } return { checkoutPr, checkoutCommit, @@ -31294,111 +31578,8 @@ function useGit(token) { updateSubmodule, isNeedCommit, checkoutBranch, - addRemote - }; -} -//#endregion -//#region src/utils/github.ts -function useGithub(token) { - const octokit = getOctokit(token); - const { repo, owner } = context.repo; - async function getPullRequestData(pr_number) { - const { data } = await octokit.rest.pulls.get({ - owner, - repo, - pull_number: pr_number - }); - return data; - } - async function getPullRequestFiles(pr_number) { - return octokit.paginate(octokit.rest.pulls.listFiles, { - owner, - repo, - pull_number: pr_number, - per_page: 100 - }); - } - async function getOpenPullRequestByHead(head) { - const { data } = await octokit.rest.pulls.list({ - owner, - repo, - head: `${owner}:${head}`, - state: "open" - }); - return data[0]; - } - async function createPullRequest(title, head, base, body) { - const { data } = await octokit.rest.pulls.create({ - owner, - repo, - title, - head, - base, - body - }); - return data; - } - async function getCommentList(pr_number) { - const { data } = await octokit.rest.issues.listComments({ - owner, - repo, - issue_number: pr_number - }); - return data; - } - async function addComment(pr_number, body) { - await octokit.rest.issues.createComment({ - owner, - repo, - issue_number: pr_number, - body - }); - } - async function updateComment(comment_id, body) { - await octokit.rest.issues.updateComment({ - owner, - repo, - comment_id, - body - }); - } - async function addPullRequestLabels(pr_number, labels) { - await octokit.rest.issues.addLabels({ - owner, - repo, - issue_number: pr_number, - labels - }); - } - async function getRequestedReviewers(pr_number) { - const { data } = await octokit.rest.pulls.listRequestedReviewers({ - owner, - repo, - pull_number: pr_number - }); - return data.users.map((item) => item.login); - } - async function createRelease(tag_name, name, body, target_commitish) { - await octokit.rest.repos.createRelease({ - owner, - repo, - tag_name, - name, - body, - target_commitish - }); - } - return { - getPullRequestData, - getPullRequestFiles, - getOpenPullRequestByHead, - createPullRequest, - addPullRequestLabels, - addComment, - updateComment, - getCommentList, - getRequestedReviewers, - createRelease + addRemote, + getLatestTag }; } //#endregion @@ -31496,9 +31677,7 @@ async function confirmReleaseLog(prNumber, log, token) { const isReleaseHead = log.startsWith("# 🎉 发布") || log.startsWith("# 🎉 Release"); info(`isReleaseHead: ${isReleaseHead}`); if (!isReleaseHead) return false; - let changelogFileName = "CHANGELOG.md"; const releaseHeading = log.startsWith("# 🎉 Release") ? "🎉 Release" : "🎉 发布"; - if (releaseHeading === "🎉 Release") changelogFileName = "CHANGELOG.en-US.md"; const releaseLogs = extractReleaseLogs(log, releaseHeading); info(`releaseLogs: ${JSON.stringify(releaseLogs, null, 2)}`); if (!releaseLogs.length) throw new Error("Release log does not contain any valid package sections"); @@ -31522,12 +31701,15 @@ async function confirmReleaseLog(prNumber, log, token) { for (const release of releaseDirs) { const changelog = changelogMap.get(release.name); if (!changelog) continue; + const changelogFilePath = getChangelogFilePath(release, releaseHeading === "🎉 Release" ? "en" : "zh"); globSync(`${release.dir}/.changelog/*.md`).forEach((file) => { unlinkSync(file); info(`delete file: ${file}`); }); - if (!existsSync$1(`${release.dir}/${changelogFileName}`)) writeFileSync(`${release.dir}/${changelogFileName}`, "", "utf8"); - else { + if (!existsSync$1(changelogFilePath)) { + mkdirSync(dirname$1(changelogFilePath), { recursive: true }); + writeFileSync(changelogFilePath, "", "utf8"); + } else { await exec("git", [ "fetch", "origin", @@ -31537,26 +31719,31 @@ async function confirmReleaseLog(prNumber, log, token) { "checkout", `origin/${defaultBranch}`, "--", - `${release.dir}/${changelogFileName}` + changelogFilePath ]); } - const pkgChangelog = readFileSync$1(`${release.dir}/${changelogFileName}`, "utf8"); + const pkgChangelog = readFileSync$1(changelogFilePath, "utf8"); const index = pkgChangelog.indexOf("## 🌈"); let newData = ""; if (index === -1) newData = pkgChangelog + changelog; else newData = pkgChangelog.slice(0, index) + changelog + pkgChangelog.slice(index); - writeFileSync(`${release.dir}/${changelogFileName}`, newData, "utf8"); + writeFileSync(changelogFilePath, newData, "utf8"); await exec("git", [ "add", "-A", "--", release.dir ]); + await exec("git", [ + "add", + "--", + changelogFilePath + ]); await exec("git", ["status"]); if (await isNeedCommit()) await exec("git", [ "commit", "-m", - `chore: update ${release.name} ${changelogFileName}` + `chore: update ${release.name} ${basename$1(changelogFilePath)}` ]); } await pushReleaseBranch(prData.head.ref); @@ -82872,7 +83059,7 @@ async function pull_request(token) { if (isRelease && !isForkPr && context.payload.action === "opened") { const prNumber = getPullRequestNumber(); const { addComment, getPullRequestFiles } = useGithub(token); - const { cloneRepo, checkoutBranch } = useGit(token); + const { cloneRepo, checkoutBranch, getLatestTag } = useGit(token); await cloneRepo(); await checkoutBranch(pullRequestData.head.ref); const changeFiles = await getPullRequestFiles(prNumber); @@ -82884,6 +83071,7 @@ async function pull_request(token) { info("没有更新发布版本"); return; } + const useTagChangelog = isSingleMode(); const zhComments = []; const enComments = []; const logHead = "(删除此行代表确认该日志): 修改并确认日志后删除这一行,机器人会提交到 本 PR 的 CHANGELOG.md 文件中\n"; @@ -82891,19 +83079,25 @@ async function pull_request(token) { const year = currentDate.getFullYear(); const month = String(currentDate.getMonth() + 1).padStart(2, "0"); const day = String(currentDate.getDate()).padStart(2, "0"); - for (const release of releaseDirs) if (release.tag === "latest") { - const changelogs = getStashChangelog(release.dir, release.type); - info(`changelogs: ${JSON.stringify(changelogs, null, 2)}`); - const md = renderChangelogMarkdown(changelogs.changelogs); + for (const release of releaseDirs) if (release.tag === "latest" || useTagChangelog) { + let md; + if (useTagChangelog) { + const configuredFromTag = getInput("from-tag", { trimWhitespace: true }); + const toRef = getInput("to-tag", { trimWhitespace: true }) || pullRequestData.base.ref; + const fromTag = configuredFromTag || await getLatestTag(toRef, release.tag === "latest"); + info(`tag changelog: strategy=${configuredFromTag ? "configured" : fromTag ? release.tag === "latest" ? "stable" : "prerelease" : "full-history"}, from=${fromTag || ""}, to=${toRef}`); + if (!fromTag) warning(`未找到历史 tag,将扫描 ${toRef} 的全部提交`); + md = await getTagChangelog(token, [release.name], fromTag, toRef); + } else md = renderChangelogMarkdown(getStashChangelog(release.dir, release.type).changelogs); info(`markdownChangelogs: ${md}`); - const zhBody = `# 🎉 发布 ${changelogs.pkg}\n## 🌈 ${changelogs.version} \`${year}-${month}-${day}\` \n\n${md}`; + const zhBody = `# 🎉 发布 ${release.name}\n## 🌈 ${release.version} \`${year}-${month}-${day}\` \n\n${md}`; zhComments.push(zhBody); const secretId = getInput("translate-secret-id", { trimWhitespace: true }); const secretKey = getInput("translate-secret-key", { trimWhitespace: true }); if (secretId && secretKey && md) try { const text = await translateText(secretId, secretKey, md); info(`en_md: ${text}`); - const enBody = `# 🎉 Release ${changelogs.pkg}\n## 🌈 ${changelogs.version} \`${year}-${month}-${day}\` \n\n${text}`; + const enBody = `# 🎉 Release ${release.name}\n## 🌈 ${release.version} \`${year}-${month}-${day}\` \n\n${text}`; enComments.push(enBody); } catch (err) { info(`翻译失败,${err}`); @@ -82930,15 +83124,17 @@ async function pull_request(token) { return; } for (const release of releaseDirs) { - const title = `${release.name}@${release.version}`; - const shouldCreateRelease = release.type === "flutter" || Boolean(release.changelog && release.tag === "latest"); + const usePlainTag = isSingleMode(); + const title = usePlainTag ? release.version : `${release.name}@${release.version}`; + const shouldCreateRelease = usePlainTag || release.type === "flutter" || Boolean(release.changelog && release.tag === "latest"); if (release.private) info(`${release.name} is private package, skip publish`); else if (release.type === "node") await publishRelease(release); if (shouldCreateRelease) try { info(`Creating release for ${release.name}: ${title}`); - await createRelease(title, title, release.changelog, pullRequestData.merge_commit_sha); + await createRelease(title, title, release.changelog, pullRequestData.merge_commit_sha, usePlainTag && release.tag !== "latest"); info(`${release.name} release created: ${title}`); } catch (err) { + if (usePlainTag) throw err; info(`Failed to create release for ${release.name}: ${err}`); } } diff --git a/src/github-event/issue-comment.ts b/src/github-event/issue-comment.ts index 30ce859..02753ba 100644 --- a/src/github-event/issue-comment.ts +++ b/src/github-event/issue-comment.ts @@ -1,11 +1,12 @@ import type { PullRequestData } from '../types' -import { existsSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs' +import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs' +import { basename, dirname } from 'node:path' import { cwd } from 'node:process' import * as core from '@actions/core' import { exec } from '@actions/exec' import * as github from '@actions/github' import { globSync } from 'tinyglobby' -import { checkIsForkPr, extractChangelog, extractReleaseLogs, getConfiguredPackages, getInputPkgs, getPrCommentWhitelist, getPullRequestNumber, getPullRequestReleaseDirs, stashPackageChangelog } from '../utils/common' +import { checkIsForkPr, extractChangelog, extractReleaseLogs, getChangelogFilePath, getConfiguredPackages, getInputPkgs, getPrCommentWhitelist, getPullRequestNumber, getPullRequestReleaseDirs, stashPackageChangelog } from '../utils/common' import useGit from '../utils/git' import useGithub from '../utils/github' @@ -148,12 +149,7 @@ async function confirmReleaseLog(prNumber: number, log: string, token: string) { if (!isReleaseHead) { return false } - let changelogFileName = 'CHANGELOG.md' - const releaseHeading = log.startsWith('# 🎉 Release') ? '🎉 Release' : '🎉 发布' - if (releaseHeading === '🎉 Release') { - changelogFileName = 'CHANGELOG.en-US.md' - } const releaseLogs = extractReleaseLogs(log, releaseHeading) core.info(`releaseLogs: ${JSON.stringify(releaseLogs, null, 2)}`) @@ -190,20 +186,23 @@ async function confirmReleaseLog(prNumber: number, log: string, token: string) { if (!changelog) { continue } + const changelogFilePath = getChangelogFilePath(release, releaseHeading === '🎉 Release' ? 'en' : 'zh') + const files = globSync(`${release.dir}/.changelog/*.md`) files.forEach((file) => { unlinkSync(file) core.info(`delete file: ${file}`) }) - if (!existsSync(`${release.dir}/${changelogFileName}`)) { - writeFileSync(`${release.dir}/${changelogFileName}`, '', 'utf8') + if (!existsSync(changelogFilePath)) { + mkdirSync(dirname(changelogFilePath), { recursive: true }) + writeFileSync(changelogFilePath, '', 'utf8') } else { await exec('git', ['fetch', 'origin', defaultBranch]) - await exec('git', ['checkout', `origin/${defaultBranch}`, '--', `${release.dir}/${changelogFileName}`]) + await exec('git', ['checkout', `origin/${defaultBranch}`, '--', changelogFilePath]) } - const pkgChangelog = readFileSync(`${release.dir}/${changelogFileName}`, 'utf8') + const pkgChangelog = readFileSync(changelogFilePath, 'utf8') const index = pkgChangelog.indexOf('## 🌈') let newData = '' if (index === -1) { @@ -212,12 +211,13 @@ async function confirmReleaseLog(prNumber: number, log: string, token: string) { else { newData = pkgChangelog.slice(0, index) + changelog + pkgChangelog.slice(index) } - writeFileSync(`${release.dir}/${changelogFileName}`, newData, 'utf8') + writeFileSync(changelogFilePath, newData, 'utf8') await exec('git', ['add', '-A', '--', release.dir]) + await exec('git', ['add', '--', changelogFilePath]) await exec('git', ['status']) if (await isNeedCommit()) { - const commitMsg = `chore: update ${release.name} ${changelogFileName}` + const commitMsg = `chore: update ${release.name} ${basename(changelogFilePath)}` await exec('git', ['commit', '-m', commitMsg]) } } diff --git a/src/github-event/pull-request.ts b/src/github-event/pull-request.ts index fbfebaa..3ca6789 100644 --- a/src/github-event/pull-request.ts +++ b/src/github-event/pull-request.ts @@ -1,8 +1,8 @@ import type { PullRequestData } from '../types' import { cwd } from 'node:process' -import { getInput, info, setOutput } from '@actions/core' +import { getInput, info, setOutput, warning } from '@actions/core' import * as github from '@actions/github' -import { buildReleaseComments, extractChangelog, getConfiguredPackages, getInputPkgs, getPullRequestNumber, getPullRequestReleaseDirs, getStashChangelog, publishRelease, renderChangelogMarkdown, sortReleasePackages } from '../utils' +import { buildReleaseComments, extractChangelog, getConfiguredPackages, getInputPkgs, getPullRequestNumber, getPullRequestReleaseDirs, getStashChangelog, getTagChangelog, isSingleMode, publishRelease, renderChangelogMarkdown, sortReleasePackages } from '../utils' import useGit from '../utils/git' import useGithub from '../utils/github' import { translateText } from '../utils/translate' @@ -42,18 +42,20 @@ export async function pull_request(token: string) { if (isRelease && !isForkPr && github.context.payload.action === 'opened') { const prNumber = getPullRequestNumber() const { addComment, getPullRequestFiles } = useGithub(token) - const { cloneRepo, checkoutBranch } = useGit(token) + const { cloneRepo, checkoutBranch, getLatestTag } = useGit(token) await cloneRepo() await checkoutBranch(pullRequestData.head.ref) const changeFiles = await getPullRequestFiles(prNumber) info(`changeFiles: ${JSON.stringify(changeFiles, null, 2)}`) - const releaseDirs = await getPullRequestReleaseDirs(changeFiles, getConfiguredPackages(cwd())) + const configuredPackages = getConfiguredPackages(cwd()) + const releaseDirs = await getPullRequestReleaseDirs(changeFiles, configuredPackages) info(`releaseDirs: ${JSON.stringify(releaseDirs, null, 2)}`) setOutput('changelog', '') if (!releaseDirs.length) { info('没有更新发布版本') return } + const useTagChangelog = isSingleMode() const zhComments: string[] = [] const enComments: string[] = [] const logHead = '(删除此行代表确认该日志): 修改并确认日志后删除这一行,机器人会提交到 本 PR 的 CHANGELOG.md 文件中\n' @@ -63,13 +65,25 @@ export async function pull_request(token: string) { const day = String(currentDate.getDate()).padStart(2, '0') for (const release of releaseDirs) { - if (release.tag === 'latest') { - const changelogs = getStashChangelog(release.dir, release.type) - info(`changelogs: ${JSON.stringify(changelogs, null, 2)}`) - const md = renderChangelogMarkdown(changelogs.changelogs) + if (release.tag === 'latest' || useTagChangelog) { + let md: string + if (useTagChangelog) { + const configuredFromTag = getInput('from-tag', { trimWhitespace: true }) + const toRef = getInput('to-tag', { trimWhitespace: true }) || pullRequestData.base.ref + const fromTag = configuredFromTag || await getLatestTag(toRef, release.tag === 'latest') + const strategy = configuredFromTag ? 'configured' : fromTag ? release.tag === 'latest' ? 'stable' : 'prerelease' : 'full-history' + info(`tag changelog: strategy=${strategy}, from=${fromTag || ''}, to=${toRef}`) + if (!fromTag) + warning(`未找到历史 tag,将扫描 ${toRef} 的全部提交`) + md = await getTagChangelog(token, [release.name], fromTag, toRef) + } + else { + const changelogs = getStashChangelog(release.dir, release.type) + md = renderChangelogMarkdown(changelogs.changelogs) + } info(`markdownChangelogs: ${md}`) // 中文日志 - const zhBody = `# 🎉 发布 ${changelogs.pkg}\n## 🌈 ${changelogs.version} \`${year}-${month}-${day}\` \n\n${md}` + const zhBody = `# 🎉 发布 ${release.name}\n## 🌈 ${release.version} \`${year}-${month}-${day}\` \n\n${md}` zhComments.push(zhBody) const secretId = getInput('translate-secret-id', { trimWhitespace: true }) @@ -79,7 +93,7 @@ export async function pull_request(token: string) { try { const text = await translateText(secretId, secretKey, md) info(`en_md: ${text}`) - const enBody = `# 🎉 Release ${changelogs.pkg}\n## 🌈 ${changelogs.version} \`${year}-${month}-${day}\` \n\n${text}` + const enBody = `# 🎉 Release ${release.name}\n## 🌈 ${release.version} \`${year}-${month}-${day}\` \n\n${text}` enComments.push(enBody) } catch (err) { @@ -118,8 +132,9 @@ export async function pull_request(token: string) { return } for (const release of releaseDirs) { - const title = `${release.name}@${release.version}` - const shouldCreateRelease = release.type === 'flutter' || Boolean(release.changelog && release.tag === 'latest') + const usePlainTag = isSingleMode() + const title = usePlainTag ? release.version : `${release.name}@${release.version}` + const shouldCreateRelease = usePlainTag || release.type === 'flutter' || Boolean(release.changelog && release.tag === 'latest') if (release.private) { info(`${release.name} is private package, skip publish`) @@ -131,10 +146,12 @@ export async function pull_request(token: string) { if (shouldCreateRelease) { try { info(`Creating release for ${release.name}: ${title}`) - await createRelease(title, title, release.changelog, pullRequestData.merge_commit_sha) + await createRelease(title, title, release.changelog, pullRequestData.merge_commit_sha, usePlainTag && release.tag !== 'latest') info(`${release.name} release created: ${title}`) } catch (err) { + if (usePlainTag) + throw err info(`Failed to create release for ${release.name}: ${err}`) } } diff --git a/src/types.ts b/src/types.ts index a393c66..29aaf60 100644 --- a/src/types.ts +++ b/src/types.ts @@ -9,6 +9,7 @@ export interface ReleasePackage { dir: string name: string version: string + oldVersion: string type: PackageType private: boolean tag: string diff --git a/src/utils/common.ts b/src/utils/common.ts index c766bb2..4067906 100644 --- a/src/utils/common.ts +++ b/src/utils/common.ts @@ -10,9 +10,34 @@ import { marked } from 'marked' import { globSync } from 'tinyglobby' import { parse } from 'yaml' import { CHANGELOG_REG, NEW_VERSION_REG, OLD_VERSION_REG, SKIP_CHANGELOG_REG } from '../consts' -import { getPackages } from './get-packages' +import { getPackages, getSinglePackage } from './get-packages' +import useGithub from './github' -export { getPackages } +export { getPackages, getSinglePackage } + +export function getMode(): 'single' | 'monorepo' { + return core.getInput('mode', { trimWhitespace: true }) === 'single' ? 'single' : 'monorepo' +} + +export function isSingleMode(): boolean { + return getMode() === 'single' +} + +function getEnglishChangelogPath(path: string): string { + return path.endsWith('.md') ? path.replace(/\.md$/, '.en-US.md') : `${path}.en-US.md` +} + +export function getChangelogFilePath(release: ReleasePackage, lang: 'zh' | 'en'): string { + const customPath = core.getInput('changelog-path', { trimWhitespace: true }) + if (customPath) { + if (lang === 'en') { + return getEnglishChangelogPath(customPath) + } + return customPath + } + const fileName = lang === 'en' ? 'CHANGELOG.en-US.md' : 'CHANGELOG.md' + return `${release.dir}/${fileName}` +} const USE_PASCAL_CASE_REG = /^Use(?=[A-Z])/ const RN_TO_LF_REG = /\r\n/g @@ -273,9 +298,18 @@ function readPackageManifest(path: string, type: PackageType): Record = {} const enChangelogs: Record = {} + const customChangelogPath = isSingleMode() ? core.getInput('changelog-path', { trimWhitespace: true }).replace(/^\.\//, '') : '' + const customEnChangelogPath = customChangelogPath + ? getEnglishChangelogPath(customChangelogPath) + : '' + const singleChangelogKey = '__single__' return prFiles.filter((file) => { - if (file.filename.includes('CHANGELOG.md') && file.patch) { + const changelogKey = customChangelogPath ? singleChangelogKey : dirname(file.filename) + const isZhChangelog = customChangelogPath ? file.filename === customChangelogPath : file.filename.includes('CHANGELOG.md') + const isEnChangelog = customEnChangelogPath ? file.filename === customEnChangelogPath : file.filename.includes('CHANGELOG.en-US.md') + + if (isZhChangelog && file.patch) { const logs: string[] = [] let isSkip = false let hasNewReleaseLog = false @@ -294,10 +328,10 @@ export function getPullRequestReleaseDirs(prFiles: PullRequestFiles, packages?: logs.push(log.trimEnd()) } }) - zhChangelogs[dirname(file.filename)] = logs.join('\n') + zhChangelogs[changelogKey] = logs.join('\n') } - if (file.filename.includes('CHANGELOG.en-US.md') && file.patch) { + if (isEnChangelog && file.patch) { const logs: string[] = [] let isSkip = false let hasNewReleaseLog = false @@ -316,7 +350,7 @@ export function getPullRequestReleaseDirs(prFiles: PullRequestFiles, packages?: logs.push(log.trimEnd()) } }) - enChangelogs[dirname(file.filename)] = logs.slice(1).join('\n') + enChangelogs[changelogKey] = logs.slice(1).join('\n') } if (file.status !== 'modified') { @@ -347,7 +381,7 @@ export function getPullRequestReleaseDirs(prFiles: PullRequestFiles, packages?: }).map((file) => { const type = getManifestType(file.filename) as PackageType const packageData = readPackageManifest(file.filename, type) - const version = getChangedVersions(file.patch || '', type).newVersion as string + const { newVersion: version, oldVersion } = getChangedVersions(file.patch || '', type) if (typeof packageData.name !== 'string') throw new Error(`Package manifest "${file.filename}" is missing a valid "name" field`) if (typeof packageData.version !== 'string' && typeof packageData.version !== 'number') @@ -362,15 +396,17 @@ export function getPullRequestReleaseDirs(prFiles: PullRequestFiles, packages?: if (version.includes('alpha')) { tag = 'alpha' } - let changelog = zhChangelogs[dirname(file.filename)] || '' - if (changelog && enChangelogs[dirname(file.filename)]) { - changelog = [...changelog, '\n---\n', enChangelogs[dirname(file.filename)]].join('') + const changelogKey = customChangelogPath ? singleChangelogKey : dirname(file.filename) + let changelog = zhChangelogs[changelogKey] || '' + if (changelog && enChangelogs[changelogKey]) { + changelog = [...changelog, '\n---\n', enChangelogs[changelogKey]].join('') } return { dir: dirname(file.filename), name: packageData.name, private: type === 'node' ? packageData.private === true : packageData.publish_to === 'none', version, + oldVersion: oldVersion as string, type, tag, changelog, @@ -448,6 +484,110 @@ export function renderChangelogMarkdown(changelogs: string[]) { ].filter(n => n).join('\n') } +/** + * 从 PR body 提取「基于 tag 区间」的发布日志(单仓扁平格式): + * 直接贴在 `### 📝 更新日志` 标题下的列表(无 `#### package` 分段), + * 以及 `#### all` / `#### ` 分段。返回纯日志条目(不含 `- ` 前缀)。 + */ +function extractTagChangelogLogs(markdown: string, pkgNames: string[]): string[] { + const md = parseMarkdown(markdown) + const changelogHeading = getChangelogHeading() + const pkgDepth = changelogHeading.depth + 1 + let collectLogs = false + let pkgName = '' + const logs: string[] = [] + + md.forEach((token) => { + if (token.type === changelogHeading.type && token.depth === changelogHeading.depth) { + collectLogs = token.text === changelogHeading.text + pkgName = '' + return + } + if (!collectLogs) { + return + } + if (token.type === 'heading') { + if (token.depth === pkgDepth) { + pkgName = token.text + } + else { + // 离开更新日志区块 + collectLogs = false + } + return + } + if (token.type === 'list') { + const items = token.items as Tokens.ListItem[] + if (pkgName === 'all' || pkgNames.includes(pkgName) || pkgName === '') { + const targetCount = pkgName === 'all' ? pkgNames.length : 1 + items.forEach((item) => { + if (item.type === 'list_item' && item.tokens.length) { + const text = (item.tokens[0] as Tokens.Text).text + for (let i = 0; i < targetCount; i++) { + logs.push(text) + } + } + }) + } + } + }) + return logs +} + +function isRenderableChangelogLog(log: string): boolean { + return CHANGELOG_REG.test(`- ${log}`) +} + +function getPullRequestTitleLog(title: string): string | undefined { + const normalizedTitle = title.trim() + if (!normalizedTitle) + return undefined + return isRenderableChangelogLog(normalizedTitle) ? normalizedTitle : `other: ${normalizedTitle}` +} + +/** + * 基于两个 tag(或 ref)之间已合并 PR 的 body 生成发布日志(单仓)。 + * fromRef 为空时扫描 toRef 的全部历史。 + */ +export async function getTagChangelog(token: string, pkgNames: string[], fromRef: string | undefined, toRef: string): Promise { + const { getMergedPrNumbersBetweenRefs, getPullRequestData } = useGithub(token) + const prNumbers = await getMergedPrNumbersBetweenRefs(fromRef, toRef) + const logs: string[] = [] + let failedPullRequests = 0 + + for (const prNumber of prNumbers) { + try { + const prData = await getPullRequestData(prNumber) + if (!isExtractPRLog(prData)) { + continue + } + const prLogs = extractTagChangelogLogs(prData.body || '', pkgNames).filter(isRenderableChangelogLog) + if (!prLogs.length) { + const titleLog = getPullRequestTitleLog(prData.title) + if (titleLog) { + prLogs.push(titleLog) + core.info(`getTagChangelog: PR #${prNumber} 未提供有效更新日志,使用 PR 标题`) + } + } + prLogs.forEach((log) => { + const contributor = prData.user.login === 'tdesign-bot' || CONTRIBUTOR_WITH_SPACE_REG.test(log) ? '' : ` @${prData.user.login}` + const prLink = COMMON_PR_REG.test(log) ? '' : ` ([#${prNumber}](${prData.html_url}))` + logs.push(`- ${log}${contributor}${prLink}`) + }) + } + catch (error) { + failedPullRequests++ + core.warning(`getTagChangelog: 跳过 PR #${prNumber}: ${error instanceof Error ? error.message : String(error)}`) + } + } + + core.info(`getTagChangelog: 扫描 ${prNumbers.length} 个 PR,生成 ${logs.length} 条日志`) + if (failedPullRequests) + core.warning(`getTagChangelog: ${failedPullRequests} 个 PR 查询失败,发布日志可能不完整`) + + return renderChangelogMarkdown(logs) +} + function renderChangelog(heading: string, changelogs: Record) { let content = '' const keys = Object.keys(changelogs).sort() @@ -517,6 +657,10 @@ export function getInputPkgs() { } export function getConfiguredPackages(path: string) { + if (isSingleMode()) { + const manifestPath = core.getInput('package-json-path', { trimWhitespace: true }) || resolve(path, 'package.json') + return [getSinglePackage(path, manifestPath)] + } const packageNames = getInputPkgs() const packages = getPackages(path) return packageNames.length ? packages.filter(pkg => packageNames.includes(pkg.name)) : packages diff --git a/src/utils/get-packages.ts b/src/utils/get-packages.ts index 7918132..8802c07 100644 --- a/src/utils/get-packages.ts +++ b/src/utils/get-packages.ts @@ -95,3 +95,25 @@ export function getPackages(path: string): Package[] { } }).sort((a, b) => a.relativeDir.localeCompare(b.relativeDir) || a.type.localeCompare(b.type)) } + +export function getSinglePackage(rootDir: string, manifestPath: string): Package { + const absolutePath = resolve(rootDir, manifestPath) + const manifest = parseManifest(absolutePath) + if (!manifest || typeof manifest !== 'object' || !('name' in manifest) || typeof manifest.name !== 'string' || !manifest.name.trim()) { + throw new Error(`Package manifest "${absolutePath}" is missing a valid "name" field`) + } + + const dir = dirname(absolutePath) + const relativeDir = relative(rootDir, dir).split(sep).join('/') || '.' + const type = basename(absolutePath) === 'package.json' ? 'node' as const : 'flutter' as const + + return { + name: manifest.name, + version: typeof manifest.version === 'string' ? manifest.version : undefined, + type, + private: type === 'node' ? manifest.private === true : manifest.publish_to === 'none', + dependencies: type === 'node' ? getNodeDependencies(manifest) : [], + dir, + relativeDir, + } +} diff --git a/src/utils/git.ts b/src/utils/git.ts index aca68ee..7d5b46e 100644 --- a/src/utils/git.ts +++ b/src/utils/git.ts @@ -54,6 +54,31 @@ export default function useGit(token: string) { await exec('git', ['fetch', origin]) } + async function getLatestTag(ref: string, stableOnly = false) { + const args = ['describe', '--tags', '--abbrev=0'] + if (stableOnly) { + args.push( + '--exclude', + '*alpha*', + '--exclude', + '*Alpha*', + '--exclude', + '*ALPHA*', + '--exclude', + '*beta*', + '--exclude', + '*Beta*', + '--exclude', + '*BETA*', + ) + } + const describe = (target: string) => getExecOutput('git', [...args, target], { ignoreReturnCode: true }) + let result = await describe(ref) + if (result.exitCode !== 0 && !ref.startsWith('origin/')) + result = await describe(`origin/${ref}`) + return result.exitCode === 0 ? result.stdout.trim() || undefined : undefined + } + return { checkoutPr, checkoutCommit, @@ -66,5 +91,6 @@ export default function useGit(token: string) { isNeedCommit, checkoutBranch, addRemote, + getLatestTag, } } diff --git a/src/utils/github.ts b/src/utils/github.ts index 67bef95..dacf25b 100644 --- a/src/utils/github.ts +++ b/src/utils/github.ts @@ -1,3 +1,4 @@ +import * as core from '@actions/core' import * as github from '@actions/github' export default function useGithub(token: string) { @@ -82,7 +83,7 @@ export default function useGithub(token: string) { }) return data.users.map(item => item.login) } - async function createRelease(tag_name: string, name: string, body: string, target_commitish?: string) { + async function createRelease(tag_name: string, name: string, body: string, target_commitish?: string, prerelease = false) { await octokit.rest.repos.createRelease({ owner, repo, @@ -90,7 +91,69 @@ export default function useGithub(token: string) { name, body, target_commitish, + prerelease, }) } - return { getPullRequestData, getPullRequestFiles, getOpenPullRequestByHead, createPullRequest, addPullRequestLabels, addComment, updateComment, getCommentList, getRequestedReviewers, createRelease } + + async function getCommitsBetweenRefs(base: string | undefined, head: string) { + if (!base) { + const commits = await octokit.paginate(octokit.rest.repos.listCommits, { + owner, + repo, + sha: head, + per_page: 100, + }) + return commits.reverse() + } + + type Commit = Awaited>['data'][number] + type CompareData = Awaited>['data'] + return octokit.paginate( + octokit.rest.repos.compareCommitsWithBasehead, + { + owner, + repo, + basehead: `${base}...${head}`, + per_page: 100, + }, + (response) => { + const data = response.data as unknown as CompareData | Commit[] + return Array.isArray(data) ? data : data.commits + }, + ) + } + + /** + * 获取 base..head 之间已合并 PR 的编号列表(去重)。 + * base 为空时扫描 head 的全部历史。单个提交查询失败时告警并继续。 + */ + async function getMergedPrNumbersBetweenRefs(base: string | undefined, head: string) { + const commits = await getCommitsBetweenRefs(base, head) + const prNumbers = new Set() + let failedCommits = 0 + for (const commit of commits) { + try { + const prs = await octokit.paginate(octokit.rest.repos.listPullRequestsAssociatedWithCommit, { + owner, + repo, + commit_sha: commit.sha, + per_page: 100, + }) + prs.forEach((pr) => { + if (pr.number && pr.merged_at) + prNumbers.add(pr.number) + }) + } + catch (error) { + failedCommits++ + core.warning(`getMergedPrNumbersBetweenRefs: 跳过 commit ${commit.sha}: ${error instanceof Error ? error.message : String(error)}`) + } + } + core.info(`getMergedPrNumbersBetweenRefs: 扫描 ${commits.length} 个 commit,关联 ${prNumbers.size} 个 PR`) + if (failedCommits) + core.warning(`getMergedPrNumbersBetweenRefs: ${failedCommits} 个 commit 查询失败,发布日志可能不完整`) + return [...prNumbers] + } + + return { getPullRequestData, getPullRequestFiles, getOpenPullRequestByHead, createPullRequest, addPullRequestLabels, addComment, updateComment, getCommentList, getRequestedReviewers, createRelease, getMergedPrNumbersBetweenRefs } } diff --git a/test/__snapshots__/utils.test.ts.snap b/test/__snapshots__/utils.test.ts.snap index f5c3ebd..0f818c3 100644 --- a/test/__snapshots__/utils.test.ts.snap +++ b/test/__snapshots__/utils.test.ts.snap @@ -135,6 +135,7 @@ exports[`utils > getMergedPullRequestReleaseDirs 1`] = ` ", "dir": "fixtures/repo1/packages/pkg-a", "name": "pkg-a", + "oldVersion": "1.0.0", "private": false, "tag": "latest", "type": "node", @@ -144,6 +145,7 @@ exports[`utils > getMergedPullRequestReleaseDirs 1`] = ` "changelog": "", "dir": "fixtures/repo1/packages/pkg-c", "name": "pkg-c", + "oldVersion": "1.0.0", "private": false, "tag": "latest", "type": "node", @@ -168,6 +170,7 @@ exports[`utils > getMergedPullRequestReleaseDirs 2`] = ` ", "dir": "fixtures/repo1/packages/pkg-a", "name": "pkg-a", + "oldVersion": "1.0.0", "private": false, "tag": "latest", "type": "node", @@ -177,6 +180,7 @@ exports[`utils > getMergedPullRequestReleaseDirs 2`] = ` "changelog": "", "dir": "fixtures/repo1/packages/pkg-c", "name": "pkg-c", + "oldVersion": "1.0.0", "private": false, "tag": "latest", "type": "node", @@ -212,6 +216,7 @@ exports[`utils > getMergedPullRequestReleaseDirs 3`] = ` ", "dir": "fixtures/repo1/packages/pkg-a", "name": "pkg-a", + "oldVersion": "1.0.0", "private": false, "tag": "latest", "type": "node", @@ -221,6 +226,7 @@ exports[`utils > getMergedPullRequestReleaseDirs 3`] = ` "changelog": "", "dir": "fixtures/repo1/packages/pkg-c", "name": "pkg-c", + "oldVersion": "1.0.0", "private": false, "tag": "latest", "type": "node", diff --git a/test/issue-comment.test.ts b/test/issue-comment.test.ts index 29bd8de..9914dd3 100644 --- a/test/issue-comment.test.ts +++ b/test/issue-comment.test.ts @@ -4,6 +4,7 @@ import { issue_comment } from '../src/github-event/issue-comment' vi.mock('node:fs', () => ({ existsSync: vi.fn().mockReturnValue(false), + mkdirSync: vi.fn(), writeFileSync: vi.fn(), readFileSync: vi.fn().mockReturnValue(''), unlinkSync: vi.fn(), @@ -55,6 +56,7 @@ vi.mock('../src/utils/common', () => ({ checkIsForkPr: () => false, extractChangelog: mocks.extractChangelog, extractReleaseLogs: vi.fn().mockReturnValue([]), + getChangelogFilePath: (release: { dir: string }, lang: 'zh' | 'en') => `${release.dir}/${lang === 'en' ? 'CHANGELOG.en-US.md' : 'CHANGELOG.md'}`, getConfiguredPackages: () => [], getInputPkgs: () => ['pkg-a'], getPrCommentWhitelist: mocks.getPrCommentWhitelist, @@ -224,8 +226,8 @@ describe('issue_comment', () => { ]) vi.mocked(getPullRequestReleaseDirs).mockReturnValue([ - { dir: 'packages/pkg-a', name: 'pkg-a', private: false, version: '1.0.1', type: 'node', tag: 'latest', changelog: '' }, - { dir: 'packages/pkg-b', name: 'pkg-b', private: false, version: '2.0.0', type: 'node', tag: 'latest', changelog: '' }, + { dir: 'packages/pkg-a', name: 'pkg-a', private: false, version: '1.0.1', oldVersion: '1.0.0', type: 'node', tag: 'latest', changelog: '' }, + { dir: 'packages/pkg-b', name: 'pkg-b', private: false, version: '2.0.0', oldVersion: '1.0.0', type: 'node', tag: 'latest', changelog: '' }, ]) mocks.context.payload = { @@ -254,8 +256,8 @@ describe('issue_comment', () => { ]) vi.mocked(getPullRequestReleaseDirs).mockReturnValue([ - { dir: 'packages/pkg-a', name: 'pkg-a', private: false, version: '1.0.1', type: 'node', tag: 'latest', changelog: '' }, - { dir: 'packages/pkg-b', name: 'pkg-b', private: false, version: '2.0.0', type: 'node', tag: 'latest', changelog: '' }, + { dir: 'packages/pkg-a', name: 'pkg-a', private: false, version: '1.0.1', oldVersion: '1.0.0', type: 'node', tag: 'latest', changelog: '' }, + { dir: 'packages/pkg-b', name: 'pkg-b', private: false, version: '2.0.0', oldVersion: '1.0.0', type: 'node', tag: 'latest', changelog: '' }, ]) mocks.context.payload = { @@ -298,7 +300,7 @@ describe('issue_comment', () => { { pkgName: 'pkg-unknown', changelog: '## 🌈 1.0.0\n\n- feature\n\n' }, ]) vi.mocked(getPullRequestReleaseDirs).mockReturnValue([ - { dir: 'packages/pkg-a', name: 'pkg-a', private: false, version: '1.0.0', type: 'node', tag: 'latest', changelog: '' }, + { dir: 'packages/pkg-a', name: 'pkg-a', private: false, version: '1.0.0', oldVersion: '1.0.0', type: 'node', tag: 'latest', changelog: '' }, ]) mocks.context.payload = { action: 'edited', @@ -317,7 +319,7 @@ describe('issue_comment', () => { { pkgName: 'pkg-a', changelog: '## 🌈 1.0.0\n\n- feature\n\n' }, ]) vi.mocked(getPullRequestReleaseDirs).mockReturnValue([ - { dir: 'packages/pkg-a', name: 'pkg-a', private: false, version: '1.0.0', type: 'node', tag: 'latest', changelog: '' }, + { dir: 'packages/pkg-a', name: 'pkg-a', private: false, version: '1.0.0', oldVersion: '1.0.0', type: 'node', tag: 'latest', changelog: '' }, ]) let pushAttempts = 0 mocks.exec.mockImplementation(async (_command, args) => { @@ -337,9 +339,9 @@ describe('issue_comment', () => { await issue_comment('token') - expect(mocks.exec).toHaveBeenCalledTimes(7) - expect(mocks.exec).toHaveBeenNthCalledWith(4, 'git', ['pull', '--rebase', 'origin', 'feat/loading']) - expect(mocks.exec).toHaveBeenNthCalledWith(6, 'git', ['pull', '--rebase', 'origin', 'feat/loading']) + expect(mocks.exec).toHaveBeenCalledTimes(8) + expect(mocks.exec).toHaveBeenNthCalledWith(5, 'git', ['pull', '--rebase', 'origin', 'feat/loading']) + expect(mocks.exec).toHaveBeenNthCalledWith(7, 'git', ['pull', '--rebase', 'origin', 'feat/loading']) expect(pushAttempts).toBe(2) }) }) diff --git a/test/publish.test.ts b/test/publish.test.ts index 480aa39..52a58f4 100644 --- a/test/publish.test.ts +++ b/test/publish.test.ts @@ -10,6 +10,7 @@ const release: ReleasePackage = { dir: 'packages/example', name: 'example', version: '1.0.0', + oldVersion: '0.0.0', type: 'node', private: false, tag: 'latest', diff --git a/test/pull-request.test.ts b/test/pull-request.test.ts new file mode 100644 index 0000000..3a573c8 --- /dev/null +++ b/test/pull-request.test.ts @@ -0,0 +1,181 @@ +import type { ReleasePackage } from '../src/types' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { pull_request } from '../src/github-event/pull-request' + +const mocks = vi.hoisted(() => ({ + addComment: vi.fn(), + checkoutBranch: vi.fn(), + checkoutCommit: vi.fn(), + cloneRepo: vi.fn(), + createRelease: vi.fn(), + inputs: { mode: 'single' } as Record, + getInput: vi.fn((name: string) => mocks.inputs[name] || ''), + getLatestTag: vi.fn(), + getPullRequestFiles: vi.fn(), + getPullRequestReleaseDirs: vi.fn(), + getTagChangelog: vi.fn(), + publishRelease: vi.fn(), + context: { + eventName: 'pull_request', + payload: {} as any, + }, +})) + +vi.mock('@actions/core', () => ({ + getInput: mocks.getInput, + info: vi.fn(), + setOutput: vi.fn(), + warning: vi.fn(), +})) +vi.mock('@actions/github', () => ({ context: mocks.context })) +vi.mock('../src/utils', () => ({ + buildReleaseComments: (head: string, sections: string[]) => sections.map(section => `${head}${section}`), + extractChangelog: vi.fn(), + getConfiguredPackages: () => [{ name: 'pkg-a' }], + getInputPkgs: () => [], + getPullRequestNumber: () => 42, + getPullRequestReleaseDirs: mocks.getPullRequestReleaseDirs, + getStashChangelog: vi.fn(), + getTagChangelog: mocks.getTagChangelog, + isSingleMode: () => mocks.inputs.mode === 'single', + publishRelease: mocks.publishRelease, + renderChangelogMarkdown: vi.fn(), + sortReleasePackages: (releases: ReleasePackage[]) => releases, +})) +vi.mock('../src/utils/git', () => ({ + default: () => ({ + checkoutBranch: mocks.checkoutBranch, + checkoutCommit: mocks.checkoutCommit, + cloneRepo: mocks.cloneRepo, + getLatestTag: mocks.getLatestTag, + }), +})) +vi.mock('../src/utils/github', () => ({ + default: () => ({ + addComment: mocks.addComment, + createRelease: mocks.createRelease, + getPullRequestFiles: mocks.getPullRequestFiles, + }), +})) +vi.mock('../src/utils/translate', () => ({ translateText: vi.fn() })) + +const baseRelease: ReleasePackage = { + dir: '.', + name: 'pkg-a', + version: '1.1.0', + oldVersion: '1.0.0', + type: 'node', + private: false, + tag: 'latest', + changelog: '', +} + +function pullRequestPayload(action: 'opened' | 'closed') { + return { + action, + number: 42, + pull_request: { + base: { ref: 'main', repo: { full_name: 'owner/repo' } }, + head: { ref: 'release/1.1.0', repo: { full_name: 'owner/repo' } }, + merge_commit_sha: action === 'closed' ? 'merge-sha' : undefined, + merged: action === 'closed', + }, + } +} + +describe('pull_request single mode', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.inputs = { mode: 'single' } + mocks.context.eventName = 'pull_request' + mocks.context.payload = pullRequestPayload('opened') + mocks.getPullRequestFiles.mockResolvedValue([]) + mocks.getPullRequestReleaseDirs.mockReturnValue([baseRelease]) + mocks.getLatestTag.mockResolvedValue('1.0.0') + mocks.getTagChangelog.mockResolvedValue('### 🚀 Features\n') + mocks.createRelease.mockResolvedValue(undefined) + }) + + it('uses the latest stable tag for a stable release', async () => { + await pull_request('token') + + expect(mocks.getLatestTag).toHaveBeenCalledWith('main', true) + expect(mocks.getTagChangelog).toHaveBeenCalledWith('token', ['pkg-a'], '1.0.0', 'main') + }) + + it('uses the latest tag and creates a comment for a prerelease', async () => { + mocks.getPullRequestReleaseDirs.mockReturnValue([{ + ...baseRelease, + version: '1.1.0-beta.1', + tag: 'beta', + }]) + + await pull_request('token') + + expect(mocks.getLatestTag).toHaveBeenCalledWith('main', false) + expect(mocks.getTagChangelog).toHaveBeenCalledWith('token', ['pkg-a'], '1.0.0', 'main') + expect(mocks.addComment).toHaveBeenCalledOnce() + }) + + it('prefers an explicitly configured from tag', async () => { + mocks.inputs['from-tag'] = '0.8.0' + + await pull_request('token') + + expect(mocks.getLatestTag).not.toHaveBeenCalled() + expect(mocks.getTagChangelog).toHaveBeenCalledWith('token', ['pkg-a'], '0.8.0', 'main') + }) + + it('uses an explicitly configured target ref', async () => { + mocks.inputs['to-tag'] = 'release-candidate' + + await pull_request('token') + + expect(mocks.getLatestTag).toHaveBeenCalledWith('release-candidate', true) + expect(mocks.getTagChangelog).toHaveBeenCalledWith('token', ['pkg-a'], '1.0.0', 'release-candidate') + }) + + it('scans the complete history when no tag exists', async () => { + mocks.getLatestTag.mockResolvedValue(undefined) + + await pull_request('token') + + expect(mocks.getTagChangelog).toHaveBeenCalledWith('token', ['pkg-a'], undefined, 'main') + }) + + it('creates a prerelease with a plain version tag', async () => { + mocks.context.payload = pullRequestPayload('closed') + mocks.getPullRequestReleaseDirs.mockReturnValue([{ + ...baseRelease, + version: '1.1.0-beta.1', + tag: 'beta', + }]) + + await pull_request('token') + + expect(mocks.createRelease).toHaveBeenCalledWith( + '1.1.0-beta.1', + '1.1.0-beta.1', + '', + 'merge-sha', + true, + ) + }) + + it('fails when a single-mode release cannot be created', async () => { + mocks.context.payload = pullRequestPayload('closed') + mocks.createRelease.mockRejectedValue(new Error('release failed')) + + await expect(pull_request('token')).rejects.toThrow('release failed') + }) + + it('keeps the monorepo release failure behavior', async () => { + mocks.inputs.mode = 'monorepo' + mocks.context.payload = pullRequestPayload('closed') + mocks.getPullRequestReleaseDirs.mockReturnValue([{ ...baseRelease, changelog: '## changelog' }]) + mocks.createRelease.mockRejectedValue(new Error('release failed')) + + await expect(pull_request('token')).resolves.toBeUndefined() + }) +}) diff --git a/test/utils.test.ts b/test/utils.test.ts index 2fdb930..4bd2e71 100644 --- a/test/utils.test.ts +++ b/test/utils.test.ts @@ -1,5 +1,5 @@ import type { Tokens } from 'marked' -import type { PullRequestData } from '../src/types' +import type { PullRequestData, ReleasePackage } from '../src/types' import { existsSync, readFileSync } from 'node:fs' import { describe, expect, it } from 'vitest' import { flutter_pull_request_files, merged_pull_request_files, merged_pull_request_files2, merged_pull_request_files3, pull_request_data, pull_request_files } from '../fixtures/pull_request_data' @@ -8,6 +8,8 @@ import { extractChangelog, extractReleaseLog, extractReleaseLogs, + getChangelogFilePath, + getConfiguredPackages, getInputPkgs, getPackages, getPullRequestReleaseDirs, @@ -164,6 +166,58 @@ describe('utils', () => { expect(paths[1].tag).toBe('latest') }) + it('associates a configured changelog path with the single package release', () => { + process.env.INPUT_MODE = 'single' + process.env['INPUT_CHANGELOG-PATH'] = 'docs/RELEASES.md' + + try { + const packages = getPackages('fixtures/repo1').filter(pkg => pkg.name === 'pkg-a') + const paths = getPullRequestReleaseDirs([ + ...pull_request_files, + { + filename: 'docs/RELEASES.md', + status: 'modified', + patch: '@@ -0,0 +1,3 @@\n+## 🌈 1.0.1\n+\n+- feature', + } as any, + ], packages) + + expect(paths).toHaveLength(1) + expect(paths[0].changelog).toContain('## 🌈 1.0.1') + } + finally { + delete process.env.INPUT_MODE + delete process.env['INPUT_CHANGELOG-PATH'] + } + }) + + it('returns localized custom changelog paths', () => { + process.env['INPUT_CHANGELOG-PATH'] = 'docs/RELEASES.md' + const release = { dir: '.' } as ReleasePackage + + try { + expect(getChangelogFilePath(release, 'zh')).toBe('docs/RELEASES.md') + expect(getChangelogFilePath(release, 'en')).toBe('docs/RELEASES.en-US.md') + } + finally { + delete process.env['INPUT_CHANGELOG-PATH'] + } + }) + + it('uses the configured manifest in single mode', () => { + process.env.INPUT_MODE = 'single' + process.env['INPUT_PACKAGE-JSON-PATH'] = 'packages/pkg-a/package.json' + + try { + expect(getConfiguredPackages('fixtures/repo1')).toMatchObject([ + { name: 'pkg-a', relativeDir: 'packages/pkg-a', type: 'node' }, + ]) + } + finally { + delete process.env.INPUT_MODE + delete process.env['INPUT_PACKAGE-JSON-PATH'] + } + }) + it('getFlutterPullRequestReleaseDirs', () => { const paths = getPullRequestReleaseDirs(flutter_pull_request_files) expect(paths).toMatchObject([ diff --git a/test/utils/get-merged-prs.test.ts b/test/utils/get-merged-prs.test.ts new file mode 100644 index 0000000..e628cff --- /dev/null +++ b/test/utils/get-merged-prs.test.ts @@ -0,0 +1,119 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import useGithub from '../../src/utils/github' + +const octokit = vi.hoisted(() => { + const rest = { + repos: { + compareCommitsWithBasehead: vi.fn(), + listCommits: vi.fn(), + listPullRequestsAssociatedWithCommit: vi.fn(), + }, + } + return { + rest, + paginate: vi.fn(async ( + route: string | ((params: unknown) => Promise<{ data: unknown }>), + params: unknown, + map?: (response: { data: unknown }) => unknown[], + ) => { + const response = typeof route === 'string' + ? await rest.repos.compareCommitsWithBasehead(params) + : await route(params) + if (map) + return map(response) + const data = response.data as { commits?: unknown[] } | unknown[] + return Array.isArray(data) ? data : data.commits || [] + }), + } +}) + +const mocks = vi.hoisted(() => ({ + info: vi.fn(), + warning: vi.fn(), +})) + +vi.mock('@actions/core', () => ({ info: mocks.info, warning: mocks.warning })) +vi.mock('@actions/github', () => ({ + getOctokit: vi.fn(() => octokit), + context: { repo: { owner: 'owner', repo: 'repo' } }, +})) + +describe('getMergedPrNumbersBetweenRefs', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('collects and dedupes PR numbers from paginated commits', async () => { + octokit.paginate.mockImplementationOnce(async (_route, _params, map) => [ + ...map!({ data: { commits: [{ sha: 'a' }, { sha: 'b' }] } }), + ...map!({ data: { commits: [{ sha: 'c' }] } }), + ]) + octokit.rest.repos.compareCommitsWithBasehead.mockResolvedValue({ + data: { + commits: [ + { sha: 'a', parents: [{ sha: 'p1' }] }, + { sha: 'b', parents: [{ sha: 'p2' }, { sha: 'p3' }] }, + { sha: 'c', parents: [{ sha: 'p4' }, { sha: 'p5' }] }, + ], + }, + }) + octokit.rest.repos.listPullRequestsAssociatedWithCommit + .mockResolvedValueOnce({ data: [{ number: 9, merged_at: '2026-01-01' }, { number: 99, merged_at: null }] }) + .mockResolvedValueOnce({ data: [{ number: 10, merged_at: '2026-01-01' }] }) + .mockResolvedValueOnce({ data: [{ number: 10, merged_at: '2026-01-01' }, { number: 11, merged_at: '2026-01-01' }] }) + + const { getMergedPrNumbersBetweenRefs } = useGithub('token') + const prs = await getMergedPrNumbersBetweenRefs('1.0.0', 'main') + + expect(prs.sort((a, b) => a - b)).toEqual([9, 10, 11]) + expect(octokit.paginate).toHaveBeenCalledWith( + octokit.rest.repos.compareCommitsWithBasehead, + expect.objectContaining({ basehead: '1.0.0...main', per_page: 100 }), + expect.any(Function), + ) + }) + + it('tolerates listPullRequestsAssociatedWithCommit failure per commit', async () => { + octokit.rest.repos.compareCommitsWithBasehead.mockResolvedValue({ + data: { + commits: [ + { sha: 'b', parents: [{ sha: 'p2' }, { sha: 'p3' }] }, + { sha: 'c', parents: [{ sha: 'p4' }, { sha: 'p5' }] }, + ], + }, + }) + octokit.rest.repos.listPullRequestsAssociatedWithCommit + .mockRejectedValueOnce(new Error('timeout')) + .mockResolvedValueOnce({ data: [{ number: 11, merged_at: '2026-01-01' }] }) + + const { getMergedPrNumbersBetweenRefs } = useGithub('token') + const prs = await getMergedPrNumbersBetweenRefs('1.0.0', 'main') + + expect(prs).toEqual([11]) + expect(mocks.warning).toHaveBeenCalledWith(expect.stringContaining('跳过 commit b')) + expect(mocks.warning).toHaveBeenCalledWith(expect.stringContaining('1 个 commit 查询失败')) + }) + + it('scans the complete head history when no base tag exists', async () => { + octokit.rest.repos.listCommits.mockResolvedValue({ + data: [{ sha: 'new' }, { sha: 'old' }], + }) + octokit.rest.repos.listPullRequestsAssociatedWithCommit + .mockResolvedValueOnce({ data: [{ number: 1, merged_at: '2026-01-01' }] }) + .mockResolvedValueOnce({ data: [{ number: 2, merged_at: '2026-01-01' }] }) + + const { getMergedPrNumbersBetweenRefs } = useGithub('token') + const prs = await getMergedPrNumbersBetweenRefs(undefined, 'main') + + expect(prs).toEqual([1, 2]) + expect(octokit.paginate).toHaveBeenCalledWith( + octokit.rest.repos.listCommits, + expect.objectContaining({ sha: 'main', per_page: 100 }), + ) + expect(octokit.rest.repos.listPullRequestsAssociatedWithCommit).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ commit_sha: 'old' }), + ) + }) +}) diff --git a/test/utils/git.test.ts b/test/utils/git.test.ts new file mode 100644 index 0000000..e64163c --- /dev/null +++ b/test/utils/git.test.ts @@ -0,0 +1,68 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import useGit from '../../src/utils/git' + +const mocks = vi.hoisted(() => ({ + exec: vi.fn(), + getExecOutput: vi.fn(), +})) + +vi.mock('@actions/exec', () => ({ + exec: mocks.exec, + getExecOutput: mocks.getExecOutput, +})) +vi.mock('@actions/github', () => ({ + context: { repo: { owner: 'owner', repo: 'repo' } }, +})) + +describe('useGit getLatestTag', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.getExecOutput.mockResolvedValue({ exitCode: 0, stdout: '1.1.0\n' }) + }) + + it('finds the nearest tag reachable from the target ref', async () => { + const { getLatestTag } = useGit('token') + + await expect(getLatestTag('main')).resolves.toBe('1.1.0') + expect(mocks.getExecOutput).toHaveBeenCalledWith( + 'git', + ['describe', '--tags', '--abbrev=0', 'main'], + { ignoreReturnCode: true }, + ) + }) + + it('excludes alpha and beta tags for stable releases', async () => { + const { getLatestTag } = useGit('token') + + await getLatestTag('release-candidate', true) + + expect(mocks.getExecOutput).toHaveBeenCalledWith( + 'git', + expect.arrayContaining(['--exclude', '*alpha*', '--exclude', '*beta*', 'release-candidate']), + { ignoreReturnCode: true }, + ) + }) + + it('returns undefined when the target history has no tag', async () => { + mocks.getExecOutput.mockResolvedValue({ exitCode: 128, stdout: '' }) + const { getLatestTag } = useGit('token') + + await expect(getLatestTag('main')).resolves.toBeUndefined() + }) + + it('falls back to the remote branch ref', async () => { + mocks.getExecOutput + .mockResolvedValueOnce({ exitCode: 128, stdout: '' }) + .mockResolvedValueOnce({ exitCode: 0, stdout: '1.0.0\n' }) + const { getLatestTag } = useGit('token') + + await expect(getLatestTag('maintenance')).resolves.toBe('1.0.0') + expect(mocks.getExecOutput).toHaveBeenNthCalledWith( + 2, + 'git', + ['describe', '--tags', '--abbrev=0', 'origin/maintenance'], + { ignoreReturnCode: true }, + ) + }) +}) diff --git a/test/utils/tag-changelog.test.ts b/test/utils/tag-changelog.test.ts new file mode 100644 index 0000000..9b3c64a --- /dev/null +++ b/test/utils/tag-changelog.test.ts @@ -0,0 +1,161 @@ +import type { PullRequestData } from '../../src/types' +import { describe, expect, it, vi } from 'vitest' + +import { getTagChangelog } from '../../src/utils/common' + +const mocks = vi.hoisted(() => ({ + getMergedPrNumbersBetweenRefs: vi.fn(), + getPullRequestData: vi.fn(), + info: vi.fn(), + warning: vi.fn(), +})) + +vi.mock('@actions/core', () => ({ + info: mocks.info, + warning: mocks.warning, +})) + +vi.mock('../../src/utils/github', () => ({ + default: () => ({ + getMergedPrNumbersBetweenRefs: mocks.getMergedPrNumbersBetweenRefs, + getPullRequestData: mocks.getPullRequestData, + }), +})) + +function prData(overrides: Partial = {}): PullRequestData { + return { + number: 1, + title: 'Default pull request title', + html_url: 'https://github.com/owner/repo/pull/1', + user: { login: 'alice', type: 'User' } as any, + head: { ref: 'feat/a' } as any, + labels: [], + body: '', + ...overrides, + } as PullRequestData +} + +describe('getTagChangelog', () => { + it('collects flat changelog from merged PRs and renders grouped markdown', async () => { + mocks.getMergedPrNumbersBetweenRefs.mockResolvedValue([1, 2]) + mocks.getPullRequestData.mockImplementation(async (n: number) => { + if (n === 1) { + return prData({ + number: 1, + html_url: 'https://github.com/owner/repo/pull/1', + body: '### 📝 更新日志\n\n- fix(aa): aa', + }) + } + return prData({ + number: 2, + html_url: 'https://github.com/owner/repo/pull/2', + body: '### 📝 更新日志\n\n- feat(bb): bb', + }) + }) + + const md = await getTagChangelog('token', ['pkg-a'], '1.0.0', 'main') + + expect(md).toContain('### 🐞 Bug Fixes') + expect(md).toContain('- `Aa`: aa @alice ([#1](https://github.com/owner/repo/pull/1))') + expect(md).toContain('### 🚀 Features') + expect(md).toContain('- `Bb`: bb @alice ([#2](https://github.com/owner/repo/pull/2))') + }) + + it('skips Bot and skip-changelog PRs', async () => { + mocks.getMergedPrNumbersBetweenRefs.mockResolvedValue([1, 2, 3]) + mocks.getPullRequestData.mockImplementation(async (n: number) => { + if (n === 1) { + return prData({ number: 1, user: { login: 'bot', type: 'Bot' } as any, body: '### 📝 更新日志\n\n- fix(x): x' }) + } + if (n === 2) { + return prData({ number: 2, labels: [{ name: 'skip-changelog' }] as any, body: '### 📝 更新日志\n\n- fix(y): y' }) + } + return prData({ number: 3, html_url: 'https://github.com/owner/repo/pull/3', body: '### 📝 更新日志\n\n- feat(z): z' }) + }) + + const md = await getTagChangelog('token', ['pkg-a'], '1.0.0', 'main') + + expect(md).not.toContain('`X`') + expect(md).not.toContain('`Y`') + expect(md).toContain('- `Z`: z @alice ([#3](https://github.com/owner/repo/pull/3))') + }) + + it('also collects #### all / #### pkgName sections and tolerates getPullRequestData failure', async () => { + mocks.getMergedPrNumbersBetweenRefs.mockResolvedValue([4, 5]) + mocks.getPullRequestData.mockImplementation(async (n: number) => { + if (n === 4) + throw new Error('not found') + return prData({ + number: 5, + html_url: 'https://github.com/owner/repo/pull/5', + body: '### 📝 更新日志\n\n#### all\n- docs(cc): cc\n#### pkg-a\n- perf(dd): dd', + }) + }) + + const md = await getTagChangelog('token', ['pkg-a'], '1.0.0', 'main') + + expect(md).toContain('- `Cc`: cc @alice ([#5]') + expect(md).toContain('- `Dd`: dd @alice ([#5]') + expect(mocks.warning).toHaveBeenCalledWith(expect.stringContaining('跳过 PR #4')) + expect(mocks.warning).toHaveBeenCalledWith(expect.stringContaining('1 个 PR 查询失败')) + }) + + it('uses a conventional PR title when the body has no changelog', async () => { + mocks.getMergedPrNumbersBetweenRefs.mockResolvedValue([6]) + mocks.getPullRequestData.mockResolvedValue(prData({ + number: 6, + title: 'fix(Button): correct loading state', + html_url: 'https://github.com/owner/repo/pull/6', + body: '', + })) + + const md = await getTagChangelog('token', ['pkg-a'], '1.0.0', 'main') + + expect(md).toContain('### 🐞 Bug Fixes') + expect(md).toContain('- `Button`: correct loading state @alice ([#6]') + expect(mocks.info).toHaveBeenCalledWith(expect.stringContaining('使用 PR 标题')) + }) + + it('puts a non-conventional title in Others when the template is left unchecked', async () => { + mocks.getMergedPrNumbersBetweenRefs.mockResolvedValue([7]) + mocks.getPullRequestData.mockResolvedValue(prData({ + number: 7, + title: 'Improve loading behavior', + html_url: 'https://github.com/owner/repo/pull/7', + body: '### 📝 更新日志\n\n- [ ] 本条 PR 不需要纳入 Changelog', + })) + + const md = await getTagChangelog('token', ['pkg-a'], '1.0.0', 'main') + + expect(md).toContain('### 🚧 Others') + expect(md).toContain('- Improve loading behavior @alice ([#7]') + expect(md).not.toContain('本条 PR 不需要纳入 Changelog') + }) + + it('prefers a valid body changelog over the PR title', async () => { + mocks.getMergedPrNumbersBetweenRefs.mockResolvedValue([8]) + mocks.getPullRequestData.mockResolvedValue(prData({ + number: 8, + title: 'fix(Title): title fallback', + body: '### 📝 更新日志\n\n- feat(Body): body changelog', + })) + + const md = await getTagChangelog('token', ['pkg-a'], '1.0.0', 'main') + + expect(md).toContain('- `Body`: body changelog') + expect(md).not.toContain('title fallback') + }) + + it('does not use the title when the PR explicitly skips changelog', async () => { + mocks.getMergedPrNumbersBetweenRefs.mockResolvedValue([9]) + mocks.getPullRequestData.mockResolvedValue(prData({ + number: 9, + title: 'fix(Button): should not appear', + body: '### 📝 更新日志\n\n- [x] 本条 PR 不需要纳入 Changelog', + })) + + const md = await getTagChangelog('token', ['pkg-a'], '1.0.0', 'main') + + expect(md).toBe('') + }) +})