From e4d78a498ebd0cd9a16dc6115afdd4f00002c4c9 Mon Sep 17 00:00:00 2001 From: liweijie <674416404@qq.com> Date: Fri, 24 Jul 2026 22:40:40 +0800 Subject: [PATCH 1/5] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E5=9F=BA?= =?UTF-8?q?=E4=BA=8E=20tag=20=E7=9A=84=E5=8F=91=E5=B8=83=E6=97=A5=E5=BF=97?= =?UTF-8?q?=E7=94=9F=E6=88=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 单仓模式(`mode: single`)下,直接扫描 tag 区间内已合并 PR 的 body 生成发布日志,不再依赖 `.changelog/*.md` 暂存文件。Release tag 使用纯版本号,并支持自定义 `CHANGELOG.md` 路径与 `from-tag` / `to-tag` 参数。 --- ...tag-based-changelog-generation_22c65e5f.md | 128 +++++++ README.md | 28 ++ action.yml | 16 + dist/index.mjs | 317 +++++++++++------- src/github-event/issue-comment.ts | 23 +- src/github-event/pull-request.ts | 27 +- src/types.ts | 1 + src/utils/common.ts | 112 ++++++- src/utils/get-packages.ts | 22 ++ src/utils/github.ts | 34 +- test/__snapshots__/utils.test.ts.snap | 6 + test/issue-comment.test.ts | 12 +- test/publish.test.ts | 1 + test/utils/get-merged-prs.test.ts | 63 ++++ test/utils/tag-changelog.test.ts | 97 ++++++ 15 files changed, 745 insertions(+), 142 deletions(-) create mode 100644 .codebuddy/plans/tag-based-changelog-generation_22c65e5f.md create mode 100644 test/utils/get-merged-prs.test.ts create mode 100644 test/utils/tag-changelog.test.ts diff --git a/.codebuddy/plans/tag-based-changelog-generation_22c65e5f.md b/.codebuddy/plans/tag-based-changelog-generation_22c65e5f.md new file mode 100644 index 0000000..fcedc0a --- /dev/null +++ b/.codebuddy/plans/tag-based-changelog-generation_22c65e5f.md @@ -0,0 +1,128 @@ +--- +name: tag-based-changelog-generation +overview: 为单仓新增「基于 tag 的发布日志生成」能力:在 release PR 评论生成环节,以「最新 tag → HEAD(或用户指定的 from/to tag)之间已合并 PR」为来源,逐个抓取 PR body 中的 `### 📝 更新日志`,渲染成 `# 🎉 发布` / `# 🎉 Release` 确认评论,复用现有确认与发布流程。mono repo 仍走原有 `.changelog/*.md` 暂存逻辑,本能力仅作用于单仓。 +todos: + - id: extend-github-api + content: 扩展 github.ts 新增 listTags 与区间 PR 扫描方法 + status: completed + - id: add-tag-changelog-util + content: 在 common.ts 新增 getTagChangelog 整合抓取与渲染 + status: completed + dependencies: + - extend-github-api + - id: integrate-release-pr + content: 在 pull-request.ts 接入 tag-changelog 单仓分支 + status: completed + dependencies: + - extend-github-api + - add-tag-changelog-util + - id: update-action-docs + content: 更新 action.yml 与 README 新增参数说明 + status: completed + dependencies: + - integrate-release-pr + - id: add-unit-tests + content: 为新增工具补充 vitest 单测与 fixtures + status: completed + dependencies: + - extend-github-api + - add-tag-changelog-util +--- + +## 用户需求 + +为 FlowPilot 新增「基于 tag 的发布日志生成」能力,面向**单仓**场景,作为现有 mono repo 暂存式 changelog 的替代来源。 + +## 产品概述 + +在 release PR 打开时,不再(仅)依赖各 PR 写入的 `.changelog/*.md` 暂存文件,而是直接扫描「两个 tag 之间」已合并的 PR 列表,逐个读取其 PR body 中的 `### 📝 更新日志` 区块,合并且渲染为与现有流程一致的 `# 🎉 发布` / `# 🎉 Release` 确认评论。下游的评论确认、CHANGELOG 写入与 GitHub Release 创建逻辑完全复用。 + +## 核心特性 + +- 区间确定:默认 `from = 仓库最新 tag`、`to = base 分支 HEAD`;支持可选 `from-tag` / `to-tag` 输入覆盖。 +- PR 列表获取:对区间内已合并 PR 去重,逐个抓取 body。 +- 日志抓取与过滤:从 PR body 提取 `### 📝 更新日志`,复用现有跳过规则(Bot / `skip-changelog` 标签 / release 分支 / 勾选跳过)。 +- 单仓合并渲染:忽略 PR body 中的 `#### package` 分包标题,将 `all` 与各包名日志合并为单一列表,按类型分组渲染。 +- 产出一致:生成与现有 release PR 完全同构的确认评论与贡献者/PR 链接,下游无需改动。 +- 作用边界:`tag-changelog` 开关开启且配置包数为 1(单仓)时启用;mono repo 或开关关闭时维持原暂存逻辑。 + +## 技术栈 + +- 沿用现有 GitHub Action 技术栈:TypeScript + `@actions/github`(octokit)+ `@actions/core`/`@actions/exec` + `marked` 解析 markdown,`tsdown` 构建,`vitest` 测试。 +- 不引入新依赖;复用现有 `extractChangelog` / `renderChangelogMarkdown` / `isExtractPRLog` 与 `stashPackageChangelog` 的贡献者/链接拼接规则。 + +## 实现方案 + +### 总体策略 + +在 release PR `opened` 评论生成环节(`src/github-event/pull-request.ts` 第 65-90 行),以 `tag-changelog` 开关(且单仓)为条件,将 `getStashChangelog(release.dir, release.type)` 替换为新增的 `getTagChangelog(token, [pkgName], fromRef, toRef)`。新工具负责:扫描区间已合并 PR → 逐个取 body → 过滤 → 抓取 → 统一拼接贡献者/PR 链接 → `renderChangelogMarkdown` 渲染。版本号与评论标题仍来自 `getPullRequestReleaseDirs`(release PR diff),保持不变。 + +### 关键技术决策 + +1. **区间 PR 扫描方式**:采用 `octokit.rest.repos.compareCommitsWithBasehead({ base, head })` 取 head 相对 base 的 commits,筛选 merge commit,再对每个 merge commit 调 `repos.listPullRequestsAssociatedWithCommit` 取得 PR 编号并去重。理由:纯 API、无需额外 clone,与现有 `useGithub` 封装风格一致。 + +- 风险:`compareCommitsWithBasehead` 在超大区间存在约 250 commits 截断。缓解:对 release PR 路径(已 clone)增加 `git log .. --merges --pretty=format:%s` 解析 `#(\d+)` 的回退分支;首版可先实现 API 方案并在日志中标注截断风险。 + +2. **最新 tag 获取**:`repos.listTags({ per_page: 1 })` 取首个 tag 名作为 `from`;该调用已是 octokit 标准方法,无需 clone。 +3. **单仓日志合并**:`getTagChangelog` 以 `[pkgName]` 调 `extractChangelog`,同时收集 `all` 键与包名键下的条目,合并为统一列表;每条格式化为 `- ${log} @${login} ([#${pr}](${html_url}))`,沿用 `stashPackageChangelog` 的 `tdesign-bot`/Common PR 链接省略规则,保证与暂存文件产物格式一致,下游 `renderChangelogMarkdown` 零改动复用。 +4. **开关与边界**:`getInput('tag-changelog') === 'true'` 且 `getConfiguredPackages(cwd()).length === 1` 才启用;否则走原 `getStashChangelog`,保证 mono repo 与未开启场景零回归。 + +### 性能与可靠性 + +- 区间内 PR 数量通常为数十级,逐 PR 调 `getPullRequestData` 为 O(n) 串行请求;首版串行即可,后续可批量/并发。需对 `getPullRequestData` 失败做单 PR 容错(记录日志并跳过),避免单个 PR 异常中断整体。 +- 复用现有 `info` 日志与 `isExtractPRLog` 过滤,跳过条件与单 PR 路径完全一致。 + +## 实现注意事项 + +- 仅修改 release PR `opened` 分支,`closed`(发布)与 `issue_comment`(确认写 CHANGELOG)逻辑不动,blast radius 可控。 +- 评论文本格式(`# 🎉 发布 ` / `# 🎉 Release ` + `## 🌈 version date`)必须与现有完全一致,`confirmReleaseLog` 才能解析。 +- 新增 API 方法放入现有 `useGithub(token)` 返回对象,保持调用风格统一;不新增独立模块。 +- 测试用 mock octokit(`@actions/github` 的 `getOctokit`),覆盖:区间内多 PR、跳过 Bot/skip 标签、合并 `all` 与包名日志、渲染分组正确。 + +## 架构设计 + +```mermaid +flowchart TD + A[pull_request opened / release PR] --> B{tag-changelog 开启 且 单仓?} + B -- 否 --> C[原 getStashChangelog 暂存文件逻辑] + B -- 是 --> D[getTagChangelog] + D --> E[getMergedPrNumbersBetweenRefs 最新tag..base] + E --> F[逐 PR getPullRequestData 取 body] + F --> G[isExtractPRLog 过滤] + G --> H[extractChangelog 抓日志] + H --> I[合并 + 贡献者/链接 拼接] + I --> J[renderChangelogMarkdown] + C --> K[renderChangelogMarkdown] + J --> L[buildReleaseComments + addComment] + K --> L + L --> M[issue_comment: edited 确认 -> 写 CHANGELOG / 创建 Release] +``` + +## 目录结构 + +``` +action.yml # [MODIFY] 新增输入 tag-changelog(bool)、from-tag、to-tag +src/utils/github.ts # [MODIFY] useGithub 新增 listTags、getMergedPrNumbersBetweenRefs +src/utils/common.ts # [MODIFY] 新增 getTagChangelog,复用 extractChangelog/renderChangelogMarkdown/isExtractPRLog +src/github-event/pull-request.ts # [MODIFY] release PR opened 分支按开关+单仓切换日志来源 +README.md # [MODIFY] 新增「基于 tag 的发布日志(单仓)」小节、输入参数表、单仓 packages 可省略说明 +test/utils/tag-changelog.test.ts # [NEW] getTagChangelog 与 getMergedPrNumbersBetweenRefs 单测(mock octokit) +``` + +## 关键代码结构 + +```ts +// src/utils/github.ts —— 新增于 useGithub(token) 返回对象 +async function listTags(): Promise // 返回仓库最新 tag 名(repos.listTags per_page=1) +async function getMergedPrNumbersBetweenRefs(base: string, head: string): Promise +``` + +```ts +// src/utils/common.ts —— 新增导出 +export async function getTagChangelog( + token: string, + pkgNames: string[], + fromRef: string, + toRef: string, +): Promise // 返回渲染后的 release changelog markdown +``` \ No newline at end of file diff --git a/README.md b/README.md index f0a863f..510ddf6 100644 --- a/README.md +++ b/README.md @@ -161,6 +161,11 @@ jobs: | `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。默认取上个发布版本号(单仓为纯版本号,如 `1.2.3`)。 | +| `to-tag` | 否 | 覆盖日志区间结束 ref。默认取 release PR 的 base 分支。 | | Output | 说明 | | --- | --- | @@ -274,6 +279,29 @@ release PR 打开时,FlowPilot 读取各包的 `.changelog/*.md`,按类型 | `docs`、`doc` | 📝 Documentation | | 其他类型 | 🚧 Others | +## 基于 tag 的发布日志(单仓) + +单仓场景下,各 PR 的更新日志直接写在 PR 描述的 `### 📝 更新日志` 下(扁平列表,无 `#### package` 分段): + +```md +### 📝 更新日志 + +- fix(aa): aa +``` + +设置 `mode: single` 后,release PR 打开时 FlowPilot 会: + +1. 取上个发布版本号(或 `from-tag`)作为区间起点,release PR 的 base 分支(或 `to-tag`)作为终点。 +2. 通过 GitHub compare API 获取区间内的 merge commit,并关联出对应的已合并 PR 编号(去重)。 +3. 逐个拉取 PR body,复用与普通 PR 相同的跳过规则(Bot / `skip-changelog` 标签 / release 分支 / 手动勾选),从 `### 📝 更新日志` 抓取日志。 +4. 拼接贡献者与 PR 链接,按类型分组渲染,生成与暂存模式完全一致的 `# 🎉 发布` / `# 🎉 Release` 确认评论;下游确认与 Release 创建流程不变。 + +单仓模式下,release PR 合并后的 GitHub tag 也会使用纯版本号(如 `1.2.3`)而非 `${name}@${version}`,与区间起点保持一致。 + +可通过 `package-json-path` 指定非根目录的 `package.json`,通过 `changelog-path` 指定自定义的 `CHANGELOG.md` 读写位置。 + +注意:`compareCommitsWithBasehead` 在超大区间存在 commits 截断,超大版本跨度下建议显式传入 `from-tag` / `to-tag` 缩小区间。 + ## Release 流程 ### 1. 创建 release PR diff --git a/action.yml b/action.yml index 95a7517..4c10633 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(默认取上个发布版本号,单仓为纯版本号) + 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..a3d954f 100644 --- a/dist/index.mjs +++ b/dist/index.mjs @@ -30866,6 +30866,137 @@ function getPackages(path) { }).sort((a, b) => a.relativeDir.localeCompare(b.relativeDir) || a.type.localeCompare(b.type)); } //#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 + }); + } + /** + * 获取 base..head 之间已合并 PR 的编号列表(去重)。 + * 通过 compare API 取 merge commit,再关联其 PR 编号;单 PR 失败容错跳过。 + */ + async function getMergedPrNumbersBetweenRefs(base, head) { + const { data } = await octokit.rest.repos.compareCommitsWithBasehead({ + owner, + repo, + basehead: `${base}...${head}` + }); + const mergeCommits = (data.commits || []).filter((commit) => (commit.parents?.length ?? 0) >= 2); + const prNumbers = /* @__PURE__ */ new Set(); + for (const commit of mergeCommits) try { + const { data: prs } = await octokit.rest.repos.listPullRequestsAssociatedWithCommit({ + owner, + repo, + commit_sha: commit.sha + }); + prs.forEach((pr) => { + if (pr.number) prNumbers.add(pr.number); + }); + } catch (error) { + info(`getMergedPrNumbersBetweenRefs: 跳过 commit ${commit.sha}:${error instanceof Error ? error.message : String(error)}`); + } + return [...prNumbers]; + } + return { + getPullRequestData, + getPullRequestFiles, + getOpenPullRequestByHead, + createPullRequest, + addPullRequestLabels, + addComment, + updateComment, + getCommentList, + getRequestedReviewers, + createRelease, + getMergedPrNumbersBetweenRefs + }; +} +//#endregion //#region src/utils/common.ts const USE_PASCAL_CASE_REG = /^Use(?=[A-Z])/; const RN_TO_LF_REG = /\r\n/g; @@ -31074,7 +31205,7 @@ 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`); @@ -31092,6 +31223,7 @@ function getPullRequestReleaseDirs(prFiles, packages) { name: packageData.name, private: type === "node" ? packageData.private === true : packageData.publish_to === "none", version, + oldVersion, type, tag, changelog @@ -31159,6 +31291,66 @@ 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; +} +/** +* 基于两个 tag(或 ref)之间已合并 PR 的 body 生成发布日志(单仓)。 +* fromRef 默认为上个发布版本号(纯版本号 tag),toRef 默认为 base 分支。 +*/ +async function getTagChangelog(token, pkgNames, fromRef, toRef) { + const { getMergedPrNumbersBetweenRefs, getPullRequestData } = useGithub(token); + const prNumbers = await getMergedPrNumbersBetweenRefs(fromRef, toRef); + const logs = []; + for (const prNumber of prNumbers) try { + const prData = await getPullRequestData(prNumber); + if (!isExtractPRLog(prData)) continue; + extractTagChangelogLogs(prData.body || "", pkgNames).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) { + info(`getTagChangelog: 跳过 PR #${prNumber}: ${error instanceof Error ? error.message : String(error)}`); + } + return renderChangelogMarkdown(logs); +} function renderChangelog(heading, changelogs) { let content = ""; const keys = Object.keys(changelogs).sort(); @@ -31298,110 +31490,6 @@ function useGit(token) { }; } //#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 - }; -} -//#endregion //#region src/github-event/issue-comment.ts const PUSH_MAX_ATTEMPTS = 3; async function issue_comment(token) { @@ -82877,13 +82965,15 @@ async function pull_request(token) { 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 = getInput("tag-changelog") === "true" && configuredPackages.length === 1; const zhComments = []; const enComments = []; const logHead = "(删除此行代表确认该日志): 修改并确认日志后删除这一行,机器人会提交到 本 PR 的 CHANGELOG.md 文件中\n"; @@ -82892,18 +82982,21 @@ async function pull_request(token) { 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); + let md; + if (useTagChangelog) { + const fromTag = getInput("from-tag", { trimWhitespace: true }) || release.oldVersion; + const toRef = getInput("to-tag", { trimWhitespace: true }) || pullRequestData.base.ref; + 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,7 +83023,7 @@ async function pull_request(token) { return; } for (const release of releaseDirs) { - const title = `${release.name}@${release.version}`; + const title = getInput("tag-changelog") === "true" && packages.length === 1 ? release.version : `${release.name}@${release.version}`; const shouldCreateRelease = 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); diff --git a/src/github-event/issue-comment.ts b/src/github-event/issue-comment.ts index 30ce859..ed36c18 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 { basename } 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,22 @@ 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)) { + 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 +210,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..db4a7a4 100644 --- a/src/github-event/pull-request.ts +++ b/src/github-event/pull-request.ts @@ -2,7 +2,7 @@ import type { PullRequestData } from '../types' import { cwd } from 'node:process' import { getInput, info, setOutput } 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' @@ -47,13 +47,15 @@ export async function pull_request(token: string) { 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' @@ -64,12 +66,19 @@ export async function pull_request(token: string) { 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) + let md: string + if (useTagChangelog) { + const fromTag = getInput('from-tag', { trimWhitespace: true }) || release.version + const toRef = getInput('to-tag', { trimWhitespace: true }) || pullRequestData.base.ref + 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 +88,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,7 +127,9 @@ export async function pull_request(token: string) { return } for (const release of releaseDirs) { - const title = `${release.name}@${release.version}` + // 单仓开启 tag-changelog 时,release tag 直接使用纯版本号(与 from-tag 解析保持一致) + const usePlainTag = isSingleMode() + const title = usePlainTag ? release.version : `${release.name}@${release.version}` const shouldCreateRelease = release.type === 'flutter' || Boolean(release.changelog && release.tag === 'latest') if (release.private) { 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..4a82835 100644 --- a/src/utils/common.ts +++ b/src/utils/common.ts @@ -10,9 +10,30 @@ 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' +} + +export function getChangelogFilePath(release: ReleasePackage, lang: 'zh' | 'en'): string { + const customPath = core.getInput('changelog-path', { trimWhitespace: true }) + if (customPath) { + if (lang === 'en') { + return customPath.replace(/\.md$/, '.en-US.md') + } + 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 @@ -347,7 +368,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') @@ -371,6 +392,7 @@ export function getPullRequestReleaseDirs(prFiles: PullRequestFiles, packages?: name: packageData.name, private: type === 'node' ? packageData.private === true : packageData.publish_to === 'none', version, + oldVersion: oldVersion as string, type, tag, changelog, @@ -448,6 +470,86 @@ 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 +} + +/** + * 基于两个 tag(或 ref)之间已合并 PR 的 body 生成发布日志(单仓)。 + * fromRef 默认为上个发布版本号(纯版本号 tag),toRef 默认为 base 分支。 + */ +export async function getTagChangelog(token: string, pkgNames: string[], fromRef: string, toRef: string): Promise { + const { getMergedPrNumbersBetweenRefs, getPullRequestData } = useGithub(token) + const prNumbers = await getMergedPrNumbersBetweenRefs(fromRef, toRef) + const logs: string[] = [] + + for (const prNumber of prNumbers) { + try { + const prData = await getPullRequestData(prNumber) + if (!isExtractPRLog(prData)) { + continue + } + const prLogs = extractTagChangelogLogs(prData.body || '', pkgNames) + 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) { + core.info(`getTagChangelog: 跳过 PR #${prNumber}: ${error instanceof Error ? error.message : String(error)}`) + } + } + + return renderChangelogMarkdown(logs) +} + function renderChangelog(heading: string, changelogs: Record) { let content = '' const keys = Object.keys(changelogs).sort() @@ -517,6 +619,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/github.ts b/src/utils/github.ts index 67bef95..c2d3741 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) { @@ -92,5 +93,36 @@ export default function useGithub(token: string) { target_commitish, }) } - return { getPullRequestData, getPullRequestFiles, getOpenPullRequestByHead, createPullRequest, addPullRequestLabels, addComment, updateComment, getCommentList, getRequestedReviewers, createRelease } + /** + * 获取 base..head 之间已合并 PR 的编号列表(去重)。 + * 通过 compare API 取 merge commit,再关联其 PR 编号;单 PR 失败容错跳过。 + */ + async function getMergedPrNumbersBetweenRefs(base: string, head: string) { + const { data } = await octokit.rest.repos.compareCommitsWithBasehead({ + owner, + repo, + basehead: `${base}...${head}`, + }) + const mergeCommits = (data.commits || []).filter(commit => (commit.parents?.length ?? 0) >= 2) + const prNumbers = new Set() + for (const commit of mergeCommits) { + try { + const { data: prs } = await octokit.rest.repos.listPullRequestsAssociatedWithCommit({ + owner, + repo, + commit_sha: commit.sha, + }) + prs.forEach((pr) => { + if (pr.number) + prNumbers.add(pr.number) + }) + } + catch (error) { + core.info(`getMergedPrNumbersBetweenRefs: 跳过 commit ${commit.sha}:${error instanceof Error ? error.message : String(error)}`) + } + } + 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..5630717 100644 --- a/test/issue-comment.test.ts +++ b/test/issue-comment.test.ts @@ -224,8 +224,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 +254,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 +298,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 +317,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) => { 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/utils/get-merged-prs.test.ts b/test/utils/get-merged-prs.test.ts new file mode 100644 index 0000000..ae69479 --- /dev/null +++ b/test/utils/get-merged-prs.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it, vi } from 'vitest' + +import useGithub from '../../src/utils/github' + +const octokit = vi.hoisted(() => ({ + rest: { + repos: { + compareCommitsWithBasehead: vi.fn(), + listPullRequestsAssociatedWithCommit: vi.fn(), + }, + }, +})) + +const mocks = vi.hoisted(() => ({ + info: vi.fn(), +})) + +vi.mock('@actions/core', () => ({ info: mocks.info })) +vi.mock('@actions/github', () => ({ + getOctokit: vi.fn(() => octokit), + context: { repo: { owner: 'owner', repo: 'repo' } }, +})) + +describe('getMergedPrNumbersBetweenRefs', () => { + it('collects and dedupes PR numbers from merge commits', async () => { + 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: 10 }] }) + .mockResolvedValueOnce({ data: [{ number: 10 }, { number: 11 }] }) + + const { getMergedPrNumbersBetweenRefs } = useGithub('token') + const prs = await getMergedPrNumbersBetweenRefs('1.0.0', 'main') + + expect(prs.sort((a, b) => a - b)).toEqual([10, 11]) + }) + + 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 }] }) + + const { getMergedPrNumbersBetweenRefs } = useGithub('token') + const prs = await getMergedPrNumbersBetweenRefs('1.0.0', 'main') + + expect(prs).toEqual([11]) + }) +}) diff --git a/test/utils/tag-changelog.test.ts b/test/utils/tag-changelog.test.ts new file mode 100644 index 0000000..9128517 --- /dev/null +++ b/test/utils/tag-changelog.test.ts @@ -0,0 +1,97 @@ +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(), +})) + +vi.mock('@actions/core', () => ({ + info: mocks.info, +})) + +vi.mock('../../src/utils/github', () => ({ + default: () => ({ + getMergedPrNumbersBetweenRefs: mocks.getMergedPrNumbersBetweenRefs, + getPullRequestData: mocks.getPullRequestData, + }), +})) + +function prData(overrides: Partial = {}): PullRequestData { + return { + number: 1, + 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]') + }) +}) From 567bf6d5b1b7c4e2e75c8fee7a94e8feff36b88c Mon Sep 17 00:00:00 2001 From: liweijie <674416404@qq.com> Date: Sun, 26 Jul 2026 19:22:20 +0800 Subject: [PATCH 2/5] =?UTF-8?q?feat:=20=E6=94=AF=E6=8C=81=E5=8D=95?= =?UTF-8?q?=E4=BB=93=E6=A8=A1=E5=BC=8F=E8=87=AA=E5=AE=9A=E4=B9=89=E5=8F=98?= =?UTF-8?q?=E6=9B=B4=E6=97=A5=E5=BF=97=E4=B8=8E=E9=A2=84=E5=8F=91=E5=B8=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 单仓模式下支持自定义 changelog 路径并匹配解析 - 新增 prerelease 参数以支持预发布版本创建 - 优化已合并 PR 提取逻辑,改为遍历所有提交并校验 merged_at - 修复获取 tag changelog 时默认 from-tag 指向旧版本号的问题 - 新增及更新相关测试用例 --- dist/index.mjs | 105 ++++++++++++++++++++------- src/github-event/issue-comment.ts | 5 +- src/github-event/pull-request.ts | 7 +- src/utils/common.ts | 30 ++++++-- src/utils/github.ts | 10 +-- test/issue-comment.test.ts | 8 +- test/pull-request.test.ts | 117 ++++++++++++++++++++++++++++++ test/utils.test.ts | 40 +++++++++- test/utils/get-merged-prs.test.ts | 9 ++- 9 files changed, 276 insertions(+), 55 deletions(-) create mode 100644 test/pull-request.test.ts diff --git a/dist/index.mjs b/dist/index.mjs index a3d954f..f3537a1 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,6 +30865,23 @@ 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) { @@ -30946,19 +30963,20 @@ function useGithub(token) { }); return data.users.map((item) => item.login); } - async function createRelease(tag_name, name, body, target_commitish) { + async function createRelease(tag_name, name, body, target_commitish, prerelease = false) { await octokit.rest.repos.createRelease({ owner, repo, tag_name, name, body, - target_commitish + target_commitish, + prerelease }); } /** * 获取 base..head 之间已合并 PR 的编号列表(去重)。 - * 通过 compare API 取 merge commit,再关联其 PR 编号;单 PR 失败容错跳过。 + * 通过 compare API 取区间提交,再关联其已合并 PR 编号;单个提交失败时容错跳过。 */ async function getMergedPrNumbersBetweenRefs(base, head) { const { data } = await octokit.rest.repos.compareCommitsWithBasehead({ @@ -30966,16 +30984,15 @@ function useGithub(token) { repo, basehead: `${base}...${head}` }); - const mergeCommits = (data.commits || []).filter((commit) => (commit.parents?.length ?? 0) >= 2); const prNumbers = /* @__PURE__ */ new Set(); - for (const commit of mergeCommits) try { + for (const commit of data.commits || []) try { const { data: prs } = await octokit.rest.repos.listPullRequestsAssociatedWithCommit({ owner, repo, commit_sha: commit.sha }); prs.forEach((pr) => { - if (pr.number) prNumbers.add(pr.number); + if (pr.number && pr.merged_at) prNumbers.add(pr.number); }); } catch (error) { info(`getMergedPrNumbersBetweenRefs: 跳过 commit ${commit.sha}:${error instanceof Error ? error.message : String(error)}`); @@ -30998,6 +31015,24 @@ function useGithub(token) { } //#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+\]/; @@ -31155,8 +31190,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; @@ -31172,9 +31213,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; @@ -31190,7 +31231,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); @@ -31212,11 +31253,12 @@ function getPullRequestReleaseDirs(prFiles, packages) { 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), @@ -31383,6 +31425,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; @@ -31584,9 +31627,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"); @@ -31610,12 +31651,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", @@ -31625,26 +31669,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); @@ -82965,15 +83014,14 @@ async function pull_request(token) { await checkoutBranch(pullRequestData.head.ref); const changeFiles = await getPullRequestFiles(prNumber); info(`changeFiles: ${JSON.stringify(changeFiles, null, 2)}`); - const configuredPackages = getConfiguredPackages(cwd()); - const releaseDirs = await getPullRequestReleaseDirs(changeFiles, configuredPackages); + const releaseDirs = await getPullRequestReleaseDirs(changeFiles, getConfiguredPackages(cwd())); info(`releaseDirs: ${JSON.stringify(releaseDirs, null, 2)}`); setOutput("changelog", ""); if (!releaseDirs.length) { info("没有更新发布版本"); return; } - const useTagChangelog = getInput("tag-changelog") === "true" && configuredPackages.length === 1; + const useTagChangelog = isSingleMode(); const zhComments = []; const enComments = []; const logHead = "(删除此行代表确认该日志): 修改并确认日志后删除这一行,机器人会提交到 本 PR 的 CHANGELOG.md 文件中\n"; @@ -83023,13 +83071,14 @@ async function pull_request(token) { return; } for (const release of releaseDirs) { - const title = getInput("tag-changelog") === "true" && packages.length === 1 ? release.version : `${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) { 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 ed36c18..02753ba 100644 --- a/src/github-event/issue-comment.ts +++ b/src/github-event/issue-comment.ts @@ -1,6 +1,6 @@ import type { PullRequestData } from '../types' -import { existsSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs' -import { basename } from 'node:path' +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' @@ -194,6 +194,7 @@ async function confirmReleaseLog(prNumber: number, log: string, token: string) { core.info(`delete file: ${file}`) }) if (!existsSync(changelogFilePath)) { + mkdirSync(dirname(changelogFilePath), { recursive: true }) writeFileSync(changelogFilePath, '', 'utf8') } else { diff --git a/src/github-event/pull-request.ts b/src/github-event/pull-request.ts index db4a7a4..22bb5fd 100644 --- a/src/github-event/pull-request.ts +++ b/src/github-event/pull-request.ts @@ -68,7 +68,7 @@ export async function pull_request(token: string) { if (release.tag === 'latest') { let md: string if (useTagChangelog) { - const fromTag = getInput('from-tag', { trimWhitespace: true }) || release.version + const fromTag = getInput('from-tag', { trimWhitespace: true }) || release.oldVersion const toRef = getInput('to-tag', { trimWhitespace: true }) || pullRequestData.base.ref md = await getTagChangelog(token, [release.name], fromTag, toRef) } @@ -127,10 +127,9 @@ export async function pull_request(token: string) { return } for (const release of releaseDirs) { - // 单仓开启 tag-changelog 时,release tag 直接使用纯版本号(与 from-tag 解析保持一致) const usePlainTag = isSingleMode() const title = usePlainTag ? release.version : `${release.name}@${release.version}` - const shouldCreateRelease = release.type === 'flutter' || Boolean(release.changelog && release.tag === 'latest') + const shouldCreateRelease = usePlainTag || release.type === 'flutter' || Boolean(release.changelog && release.tag === 'latest') if (release.private) { info(`${release.name} is private package, skip publish`) @@ -142,7 +141,7 @@ 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) { diff --git a/src/utils/common.ts b/src/utils/common.ts index 4a82835..9b5ab27 100644 --- a/src/utils/common.ts +++ b/src/utils/common.ts @@ -23,11 +23,15 @@ 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 customPath.replace(/\.md$/, '.en-US.md') + return getEnglishChangelogPath(customPath) } return customPath } @@ -294,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 @@ -315,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 @@ -337,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') { @@ -383,9 +396,10 @@ 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), diff --git a/src/utils/github.ts b/src/utils/github.ts index c2d3741..4a501d7 100644 --- a/src/utils/github.ts +++ b/src/utils/github.ts @@ -83,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, @@ -91,11 +91,12 @@ export default function useGithub(token: string) { name, body, target_commitish, + prerelease, }) } /** * 获取 base..head 之间已合并 PR 的编号列表(去重)。 - * 通过 compare API 取 merge commit,再关联其 PR 编号;单 PR 失败容错跳过。 + * 通过 compare API 取区间提交,再关联其已合并 PR 编号;单个提交失败时容错跳过。 */ async function getMergedPrNumbersBetweenRefs(base: string, head: string) { const { data } = await octokit.rest.repos.compareCommitsWithBasehead({ @@ -103,9 +104,8 @@ export default function useGithub(token: string) { repo, basehead: `${base}...${head}`, }) - const mergeCommits = (data.commits || []).filter(commit => (commit.parents?.length ?? 0) >= 2) const prNumbers = new Set() - for (const commit of mergeCommits) { + for (const commit of data.commits || []) { try { const { data: prs } = await octokit.rest.repos.listPullRequestsAssociatedWithCommit({ owner, @@ -113,7 +113,7 @@ export default function useGithub(token: string) { commit_sha: commit.sha, }) prs.forEach((pr) => { - if (pr.number) + if (pr.number && pr.merged_at) prNumbers.add(pr.number) }) } diff --git a/test/issue-comment.test.ts b/test/issue-comment.test.ts index 5630717..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, @@ -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/pull-request.test.ts b/test/pull-request.test.ts new file mode 100644 index 0000000..7fea6be --- /dev/null +++ b/test/pull-request.test.ts @@ -0,0 +1,117 @@ +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(), + getInput: vi.fn((name: string) => name === 'mode' ? 'single' : ''), + 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(), +})) +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: () => true, + 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, + }), +})) +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.context.eventName = 'pull_request' + mocks.context.payload = pullRequestPayload('opened') + mocks.getPullRequestFiles.mockResolvedValue([]) + mocks.getPullRequestReleaseDirs.mockReturnValue([baseRelease]) + mocks.getTagChangelog.mockResolvedValue('### 🚀 Features\n') + }) + + it('uses the previous version as the default changelog tag', async () => { + await pull_request('token') + + expect(mocks.getTagChangelog).toHaveBeenCalledWith('token', ['pkg-a'], '1.0.0', '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, + ) + }) +}) diff --git a/test/utils.test.ts b/test/utils.test.ts index 2fdb930..b4c671c 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,7 @@ import { extractChangelog, extractReleaseLog, extractReleaseLogs, + getChangelogFilePath, getInputPkgs, getPackages, getPullRequestReleaseDirs, @@ -164,6 +165,43 @@ 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('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 index ae69479..b677456 100644 --- a/test/utils/get-merged-prs.test.ts +++ b/test/utils/get-merged-prs.test.ts @@ -33,13 +33,14 @@ describe('getMergedPrNumbersBetweenRefs', () => { }, }) octokit.rest.repos.listPullRequestsAssociatedWithCommit - .mockResolvedValueOnce({ data: [{ number: 10 }] }) - .mockResolvedValueOnce({ data: [{ number: 10 }, { number: 11 }] }) + .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([10, 11]) + expect(prs.sort((a, b) => a - b)).toEqual([9, 10, 11]) }) it('tolerates listPullRequestsAssociatedWithCommit failure per commit', async () => { @@ -53,7 +54,7 @@ describe('getMergedPrNumbersBetweenRefs', () => { }) octokit.rest.repos.listPullRequestsAssociatedWithCommit .mockRejectedValueOnce(new Error('timeout')) - .mockResolvedValueOnce({ data: [{ number: 11 }] }) + .mockResolvedValueOnce({ data: [{ number: 11, merged_at: '2026-01-01' }] }) const { getMergedPrNumbersBetweenRefs } = useGithub('token') const prs = await getMergedPrNumbersBetweenRefs('1.0.0', 'main') From 5d2b33e3ace74a0b84b743469c83495f1a7d38f0 Mon Sep 17 00:00:00 2001 From: liweijie <674416404@qq.com> Date: Sun, 26 Jul 2026 19:31:02 +0800 Subject: [PATCH 3/5] =?UTF-8?q?chore:=20=E5=88=A0=E9=99=A4=20tag-based=20c?= =?UTF-8?q?hangelog=20=E8=AE=A1=E5=88=92=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...tag-based-changelog-generation_22c65e5f.md | 128 ------------------ 1 file changed, 128 deletions(-) delete mode 100644 .codebuddy/plans/tag-based-changelog-generation_22c65e5f.md diff --git a/.codebuddy/plans/tag-based-changelog-generation_22c65e5f.md b/.codebuddy/plans/tag-based-changelog-generation_22c65e5f.md deleted file mode 100644 index fcedc0a..0000000 --- a/.codebuddy/plans/tag-based-changelog-generation_22c65e5f.md +++ /dev/null @@ -1,128 +0,0 @@ ---- -name: tag-based-changelog-generation -overview: 为单仓新增「基于 tag 的发布日志生成」能力:在 release PR 评论生成环节,以「最新 tag → HEAD(或用户指定的 from/to tag)之间已合并 PR」为来源,逐个抓取 PR body 中的 `### 📝 更新日志`,渲染成 `# 🎉 发布` / `# 🎉 Release` 确认评论,复用现有确认与发布流程。mono repo 仍走原有 `.changelog/*.md` 暂存逻辑,本能力仅作用于单仓。 -todos: - - id: extend-github-api - content: 扩展 github.ts 新增 listTags 与区间 PR 扫描方法 - status: completed - - id: add-tag-changelog-util - content: 在 common.ts 新增 getTagChangelog 整合抓取与渲染 - status: completed - dependencies: - - extend-github-api - - id: integrate-release-pr - content: 在 pull-request.ts 接入 tag-changelog 单仓分支 - status: completed - dependencies: - - extend-github-api - - add-tag-changelog-util - - id: update-action-docs - content: 更新 action.yml 与 README 新增参数说明 - status: completed - dependencies: - - integrate-release-pr - - id: add-unit-tests - content: 为新增工具补充 vitest 单测与 fixtures - status: completed - dependencies: - - extend-github-api - - add-tag-changelog-util ---- - -## 用户需求 - -为 FlowPilot 新增「基于 tag 的发布日志生成」能力,面向**单仓**场景,作为现有 mono repo 暂存式 changelog 的替代来源。 - -## 产品概述 - -在 release PR 打开时,不再(仅)依赖各 PR 写入的 `.changelog/*.md` 暂存文件,而是直接扫描「两个 tag 之间」已合并的 PR 列表,逐个读取其 PR body 中的 `### 📝 更新日志` 区块,合并且渲染为与现有流程一致的 `# 🎉 发布` / `# 🎉 Release` 确认评论。下游的评论确认、CHANGELOG 写入与 GitHub Release 创建逻辑完全复用。 - -## 核心特性 - -- 区间确定:默认 `from = 仓库最新 tag`、`to = base 分支 HEAD`;支持可选 `from-tag` / `to-tag` 输入覆盖。 -- PR 列表获取:对区间内已合并 PR 去重,逐个抓取 body。 -- 日志抓取与过滤:从 PR body 提取 `### 📝 更新日志`,复用现有跳过规则(Bot / `skip-changelog` 标签 / release 分支 / 勾选跳过)。 -- 单仓合并渲染:忽略 PR body 中的 `#### package` 分包标题,将 `all` 与各包名日志合并为单一列表,按类型分组渲染。 -- 产出一致:生成与现有 release PR 完全同构的确认评论与贡献者/PR 链接,下游无需改动。 -- 作用边界:`tag-changelog` 开关开启且配置包数为 1(单仓)时启用;mono repo 或开关关闭时维持原暂存逻辑。 - -## 技术栈 - -- 沿用现有 GitHub Action 技术栈:TypeScript + `@actions/github`(octokit)+ `@actions/core`/`@actions/exec` + `marked` 解析 markdown,`tsdown` 构建,`vitest` 测试。 -- 不引入新依赖;复用现有 `extractChangelog` / `renderChangelogMarkdown` / `isExtractPRLog` 与 `stashPackageChangelog` 的贡献者/链接拼接规则。 - -## 实现方案 - -### 总体策略 - -在 release PR `opened` 评论生成环节(`src/github-event/pull-request.ts` 第 65-90 行),以 `tag-changelog` 开关(且单仓)为条件,将 `getStashChangelog(release.dir, release.type)` 替换为新增的 `getTagChangelog(token, [pkgName], fromRef, toRef)`。新工具负责:扫描区间已合并 PR → 逐个取 body → 过滤 → 抓取 → 统一拼接贡献者/PR 链接 → `renderChangelogMarkdown` 渲染。版本号与评论标题仍来自 `getPullRequestReleaseDirs`(release PR diff),保持不变。 - -### 关键技术决策 - -1. **区间 PR 扫描方式**:采用 `octokit.rest.repos.compareCommitsWithBasehead({ base, head })` 取 head 相对 base 的 commits,筛选 merge commit,再对每个 merge commit 调 `repos.listPullRequestsAssociatedWithCommit` 取得 PR 编号并去重。理由:纯 API、无需额外 clone,与现有 `useGithub` 封装风格一致。 - -- 风险:`compareCommitsWithBasehead` 在超大区间存在约 250 commits 截断。缓解:对 release PR 路径(已 clone)增加 `git log .. --merges --pretty=format:%s` 解析 `#(\d+)` 的回退分支;首版可先实现 API 方案并在日志中标注截断风险。 - -2. **最新 tag 获取**:`repos.listTags({ per_page: 1 })` 取首个 tag 名作为 `from`;该调用已是 octokit 标准方法,无需 clone。 -3. **单仓日志合并**:`getTagChangelog` 以 `[pkgName]` 调 `extractChangelog`,同时收集 `all` 键与包名键下的条目,合并为统一列表;每条格式化为 `- ${log} @${login} ([#${pr}](${html_url}))`,沿用 `stashPackageChangelog` 的 `tdesign-bot`/Common PR 链接省略规则,保证与暂存文件产物格式一致,下游 `renderChangelogMarkdown` 零改动复用。 -4. **开关与边界**:`getInput('tag-changelog') === 'true'` 且 `getConfiguredPackages(cwd()).length === 1` 才启用;否则走原 `getStashChangelog`,保证 mono repo 与未开启场景零回归。 - -### 性能与可靠性 - -- 区间内 PR 数量通常为数十级,逐 PR 调 `getPullRequestData` 为 O(n) 串行请求;首版串行即可,后续可批量/并发。需对 `getPullRequestData` 失败做单 PR 容错(记录日志并跳过),避免单个 PR 异常中断整体。 -- 复用现有 `info` 日志与 `isExtractPRLog` 过滤,跳过条件与单 PR 路径完全一致。 - -## 实现注意事项 - -- 仅修改 release PR `opened` 分支,`closed`(发布)与 `issue_comment`(确认写 CHANGELOG)逻辑不动,blast radius 可控。 -- 评论文本格式(`# 🎉 发布 ` / `# 🎉 Release ` + `## 🌈 version date`)必须与现有完全一致,`confirmReleaseLog` 才能解析。 -- 新增 API 方法放入现有 `useGithub(token)` 返回对象,保持调用风格统一;不新增独立模块。 -- 测试用 mock octokit(`@actions/github` 的 `getOctokit`),覆盖:区间内多 PR、跳过 Bot/skip 标签、合并 `all` 与包名日志、渲染分组正确。 - -## 架构设计 - -```mermaid -flowchart TD - A[pull_request opened / release PR] --> B{tag-changelog 开启 且 单仓?} - B -- 否 --> C[原 getStashChangelog 暂存文件逻辑] - B -- 是 --> D[getTagChangelog] - D --> E[getMergedPrNumbersBetweenRefs 最新tag..base] - E --> F[逐 PR getPullRequestData 取 body] - F --> G[isExtractPRLog 过滤] - G --> H[extractChangelog 抓日志] - H --> I[合并 + 贡献者/链接 拼接] - I --> J[renderChangelogMarkdown] - C --> K[renderChangelogMarkdown] - J --> L[buildReleaseComments + addComment] - K --> L - L --> M[issue_comment: edited 确认 -> 写 CHANGELOG / 创建 Release] -``` - -## 目录结构 - -``` -action.yml # [MODIFY] 新增输入 tag-changelog(bool)、from-tag、to-tag -src/utils/github.ts # [MODIFY] useGithub 新增 listTags、getMergedPrNumbersBetweenRefs -src/utils/common.ts # [MODIFY] 新增 getTagChangelog,复用 extractChangelog/renderChangelogMarkdown/isExtractPRLog -src/github-event/pull-request.ts # [MODIFY] release PR opened 分支按开关+单仓切换日志来源 -README.md # [MODIFY] 新增「基于 tag 的发布日志(单仓)」小节、输入参数表、单仓 packages 可省略说明 -test/utils/tag-changelog.test.ts # [NEW] getTagChangelog 与 getMergedPrNumbersBetweenRefs 单测(mock octokit) -``` - -## 关键代码结构 - -```ts -// src/utils/github.ts —— 新增于 useGithub(token) 返回对象 -async function listTags(): Promise // 返回仓库最新 tag 名(repos.listTags per_page=1) -async function getMergedPrNumbersBetweenRefs(base: string, head: string): Promise -``` - -```ts -// src/utils/common.ts —— 新增导出 -export async function getTagChangelog( - token: string, - pkgNames: string[], - fromRef: string, - toRef: string, -): Promise // 返回渲染后的 release changelog markdown -``` \ No newline at end of file From 451acfc2ccdcc07dfbba3a9e8156207ad00c77d5 Mon Sep 17 00:00:00 2001 From: liweijie <674416404@qq.com> Date: Mon, 27 Jul 2026 21:43:45 +0800 Subject: [PATCH 4/5] =?UTF-8?q?feat(single):=20=E8=87=AA=E5=8A=A8=E6=9F=A5?= =?UTF-8?q?=E6=89=BE=20tag=20=E5=8C=BA=E9=97=B4=E5=B9=B6=E5=88=86=E9=A1=B5?= =?UTF-8?q?=E6=89=AB=E6=8F=8F=20PR?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 支持 base 为空时扫描 head 的全部历史 - 通过 git describe 查找目标 ref 可达的最近 tag 作为区间起点,稳定版过滤 alpha/beta - 分页获取区间内提交及关联 PR,兼容 merge、squash 和 rebase - 单仓模式 Release/tag 创建失败时中断 workflow - 单个提交或 PR 查询失败产生 warning 并继续 --- .github/workflows/ci.yaml | 3 ++ README.md | 35 ++++++++++----- action.yml | 2 +- dist/index.mjs | 74 +++++++++++++++++++++++-------- src/github-event/pull-request.ts | 15 +++++-- src/utils/common.ts | 12 +++-- src/utils/git.ts | 26 +++++++++++ src/utils/github.ts | 51 ++++++++++++++++----- test/pull-request.test.ts | 70 +++++++++++++++++++++++++++-- test/utils.test.ts | 16 +++++++ test/utils/get-merged-prs.test.ts | 69 +++++++++++++++++++++++++--- test/utils/git.test.ts | 68 ++++++++++++++++++++++++++++ test/utils/tag-changelog.test.ts | 4 ++ 13 files changed, 388 insertions(+), 57 deletions(-) create mode 100644 test/utils/git.test.ts 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 510ddf6..8d1ba60 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,14 +157,14 @@ 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。默认取上个发布版本号(单仓为纯版本号,如 `1.2.3`)。 | +| `from-tag` | 否 | 覆盖日志区间起始 tag。单仓预发布默认取目标 ref 可达的最近 tag,稳定版默认取最近稳定 tag;目标历史无 tag 时扫描全部历史。 | | `to-tag` | 否 | 覆盖日志区间结束 ref。默认取 release PR 的 base 分支。 | | Output | 说明 | @@ -291,16 +291,27 @@ release PR 打开时,FlowPilot 读取各包的 `.changelog/*.md`,按类型 设置 `mode: single` 后,release PR 打开时 FlowPilot 会: -1. 取上个发布版本号(或 `from-tag`)作为区间起点,release PR 的 base 分支(或 `to-tag`)作为终点。 -2. 通过 GitHub compare API 获取区间内的 merge commit,并关联出对应的已合并 PR 编号(去重)。 +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 创建流程不变。 -单仓模式下,release PR 合并后的 GitHub tag 也会使用纯版本号(如 `1.2.3`)而非 `${name}@${version}`,与区间起点保持一致。 +单仓模式下,release PR 合并后的 GitHub tag 使用纯版本号(如 `1.2.3`)而非 `${name}@${version}`。alpha/beta 会创建 GitHub prerelease,并生成相对最近 tag 的增量日志;稳定版会汇总最近稳定 tag 之后的完整日志。 可通过 `package-json-path` 指定非根目录的 `package.json`,通过 `changelog-path` 指定自定义的 `CHANGELOG.md` 读写位置。 -注意:`compareCommitsWithBasehead` 在超大区间存在 commits 截断,超大版本跨度下建议显式传入 `from-tag` / `to-tag` 缩小区间。 +所有触发 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 流程 @@ -317,7 +328,7 @@ FlowPilot 根据 GitHub API 返回的 `package.json` 或 `pubspec.yaml` patch ### 2. 确认 release 日志 -release PR 打开后,FlowPilot 为 `latest` 版本生成以 `# 🎉 发布` 开头的中文确认评论。配置两个翻译参数后,还会生成以 `# 🎉 Release` 开头的英文评论。 +release PR 打开后,FlowPilot 为 monorepo 的 `latest` 版本以及单仓的所有版本生成以 `# 🎉 发布` 开头的中文确认评论。配置两个翻译参数后,还会生成以 `# 🎉 Release` 开头的英文评论。 检查评论内容并删除第一行提示后,FlowPilot 会: @@ -329,7 +340,7 @@ release PR 打开后,FlowPilot 为 `latest` 版本生成以 `# 🎉 发布` ### 3. 合并并发布 -release PR 合并后,各类包的处理方式如下: +release PR 合并后,monorepo 各类包的处理方式如下: | 包类型 | Registry 发布 | GitHub Release/tag | | --- | --- | --- | @@ -340,9 +351,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 4c10633..3de3d05 100644 --- a/action.yml +++ b/action.yml @@ -25,7 +25,7 @@ inputs: description: 单仓模式下指定 CHANGELOG.md 路径,默认在包目录下读写 CHANGELOG.md / CHANGELOG.en-US.md required: false from-tag: - description: 覆盖日志区间起始 tag(默认取上个发布版本号,单仓为纯版本号) + description: 覆盖日志区间起始 tag(默认选择目标 ref 可达的最近 tag;无 tag 时扫描全部历史) required: false to-tag: description: 覆盖日志区间结束 ref(默认取 release PR 的 base 分支) diff --git a/dist/index.mjs b/dist/index.mjs index f3537a1..00aaf82 100644 --- a/dist/index.mjs +++ b/dist/index.mjs @@ -30974,29 +30974,46 @@ function useGithub(token) { 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 的编号列表(去重)。 - * 通过 compare API 取区间提交,再关联其已合并 PR 编号;单个提交失败时容错跳过。 + * base 为空时扫描 head 的全部历史。单个提交查询失败时告警并继续。 */ async function getMergedPrNumbersBetweenRefs(base, head) { - const { data } = await octokit.rest.repos.compareCommitsWithBasehead({ - owner, - repo, - basehead: `${base}...${head}` - }); + const commits = await getCommitsBetweenRefs(base, head); const prNumbers = /* @__PURE__ */ new Set(); - for (const commit of data.commits || []) try { - const { data: prs } = await octokit.rest.repos.listPullRequestsAssociatedWithCommit({ + let failedCommits = 0; + for (const commit of commits) try { + (await octokit.paginate(octokit.rest.repos.listPullRequestsAssociatedWithCommit, { owner, repo, - commit_sha: commit.sha - }); - prs.forEach((pr) => { + commit_sha: commit.sha, + per_page: 100 + })).forEach((pr) => { if (pr.number && pr.merged_at) prNumbers.add(pr.number); }); } catch (error) { - info(`getMergedPrNumbersBetweenRefs: 跳过 commit ${commit.sha}:${error instanceof Error ? error.message : String(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 { @@ -31374,12 +31391,13 @@ function extractTagChangelogLogs(markdown, pkgNames) { } /** * 基于两个 tag(或 ref)之间已合并 PR 的 body 生成发布日志(单仓)。 -* fromRef 默认为上个发布版本号(纯版本号 tag),toRef 默认为 base 分支。 +* 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; @@ -31389,8 +31407,11 @@ async function getTagChangelog(token, pkgNames, fromRef, toRef) { logs.push(`- ${log}${contributor}${prLink}`); }); } catch (error) { - info(`getTagChangelog: 跳过 PR #${prNumber}: ${error instanceof Error ? error.message : String(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) { @@ -31518,6 +31539,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, @@ -31529,7 +31562,8 @@ function useGit(token) { updateSubmodule, isNeedCommit, checkoutBranch, - addRemote + addRemote, + getLatestTag }; } //#endregion @@ -83009,7 +83043,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); @@ -83029,11 +83063,14 @@ 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") { + for (const release of releaseDirs) if (release.tag === "latest" || useTagChangelog) { let md; if (useTagChangelog) { - const fromTag = getInput("from-tag", { trimWhitespace: true }) || release.oldVersion; + 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}`); @@ -83081,6 +83118,7 @@ async function pull_request(token) { 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/pull-request.ts b/src/github-event/pull-request.ts index 22bb5fd..3ca6789 100644 --- a/src/github-event/pull-request.ts +++ b/src/github-event/pull-request.ts @@ -1,6 +1,6 @@ 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, getTagChangelog, isSingleMode, publishRelease, renderChangelogMarkdown, sortReleasePackages } from '../utils' import useGit from '../utils/git' @@ -42,7 +42,7 @@ 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) @@ -65,11 +65,16 @@ export async function pull_request(token: string) { const day = String(currentDate.getDate()).padStart(2, '0') for (const release of releaseDirs) { - if (release.tag === 'latest') { + if (release.tag === 'latest' || useTagChangelog) { let md: string if (useTagChangelog) { - const fromTag = getInput('from-tag', { trimWhitespace: true }) || release.oldVersion + 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 { @@ -145,6 +150,8 @@ export async function pull_request(token: string) { 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/utils/common.ts b/src/utils/common.ts index 9b5ab27..b9b6970 100644 --- a/src/utils/common.ts +++ b/src/utils/common.ts @@ -536,12 +536,13 @@ function extractTagChangelogLogs(markdown: string, pkgNames: string[]): string[] /** * 基于两个 tag(或 ref)之间已合并 PR 的 body 生成发布日志(单仓)。 - * fromRef 默认为上个发布版本号(纯版本号 tag),toRef 默认为 base 分支。 + * fromRef 为空时扫描 toRef 的全部历史。 */ -export async function getTagChangelog(token: string, pkgNames: string[], fromRef: string, toRef: string): Promise { +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 { @@ -557,10 +558,15 @@ export async function getTagChangelog(token: string, pkgNames: string[], fromRef }) } catch (error) { - core.info(`getTagChangelog: 跳过 PR #${prNumber}: ${error instanceof Error ? error.message : String(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) } 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 4a501d7..dacf25b 100644 --- a/src/utils/github.ts +++ b/src/utils/github.ts @@ -94,23 +94,50 @@ export default function useGithub(token: string) { prerelease, }) } + + 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 的编号列表(去重)。 - * 通过 compare API 取区间提交,再关联其已合并 PR 编号;单个提交失败时容错跳过。 + * base 为空时扫描 head 的全部历史。单个提交查询失败时告警并继续。 */ - async function getMergedPrNumbersBetweenRefs(base: string, head: string) { - const { data } = await octokit.rest.repos.compareCommitsWithBasehead({ - owner, - repo, - basehead: `${base}...${head}`, - }) + async function getMergedPrNumbersBetweenRefs(base: string | undefined, head: string) { + const commits = await getCommitsBetweenRefs(base, head) const prNumbers = new Set() - for (const commit of data.commits || []) { + let failedCommits = 0 + for (const commit of commits) { try { - const { data: prs } = await octokit.rest.repos.listPullRequestsAssociatedWithCommit({ + 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) @@ -118,9 +145,13 @@ export default function useGithub(token: string) { }) } catch (error) { - core.info(`getMergedPrNumbersBetweenRefs: 跳过 commit ${commit.sha}:${error instanceof Error ? error.message : String(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] } diff --git a/test/pull-request.test.ts b/test/pull-request.test.ts index 7fea6be..3a573c8 100644 --- a/test/pull-request.test.ts +++ b/test/pull-request.test.ts @@ -9,7 +9,9 @@ const mocks = vi.hoisted(() => ({ checkoutCommit: vi.fn(), cloneRepo: vi.fn(), createRelease: vi.fn(), - getInput: vi.fn((name: string) => name === 'mode' ? 'single' : ''), + 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(), @@ -24,6 +26,7 @@ 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', () => ({ @@ -35,7 +38,7 @@ vi.mock('../src/utils', () => ({ getPullRequestReleaseDirs: mocks.getPullRequestReleaseDirs, getStashChangelog: vi.fn(), getTagChangelog: mocks.getTagChangelog, - isSingleMode: () => true, + isSingleMode: () => mocks.inputs.mode === 'single', publishRelease: mocks.publishRelease, renderChangelogMarkdown: vi.fn(), sortReleasePackages: (releases: ReleasePackage[]) => releases, @@ -45,6 +48,7 @@ vi.mock('../src/utils/git', () => ({ checkoutBranch: mocks.checkoutBranch, checkoutCommit: mocks.checkoutCommit, cloneRepo: mocks.cloneRepo, + getLatestTag: mocks.getLatestTag, }), })) vi.mock('../src/utils/github', () => ({ @@ -83,19 +87,63 @@ function pullRequestPayload(action: 'opened' | '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 previous version as the default changelog tag', async () => { + 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([{ @@ -114,4 +162,20 @@ describe('pull_request single mode', () => { 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 b4c671c..4bd2e71 100644 --- a/test/utils.test.ts +++ b/test/utils.test.ts @@ -9,6 +9,7 @@ import { extractReleaseLog, extractReleaseLogs, getChangelogFilePath, + getConfiguredPackages, getInputPkgs, getPackages, getPullRequestReleaseDirs, @@ -202,6 +203,21 @@ describe('utils', () => { } }) + 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 index b677456..e628cff 100644 --- a/test/utils/get-merged-prs.test.ts +++ b/test/utils/get-merged-prs.test.ts @@ -1,28 +1,54 @@ -import { describe, expect, it, vi } from 'vitest' +import { beforeEach, describe, expect, it, vi } from 'vitest' import useGithub from '../../src/utils/github' -const octokit = vi.hoisted(() => ({ - rest: { +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 })) +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', () => { - it('collects and dedupes PR numbers from merge commits', async () => { + 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: [ @@ -41,6 +67,11 @@ describe('getMergedPrNumbersBetweenRefs', () => { 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 () => { @@ -60,5 +91,29 @@ describe('getMergedPrNumbersBetweenRefs', () => { 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 index 9128517..b9ce71c 100644 --- a/test/utils/tag-changelog.test.ts +++ b/test/utils/tag-changelog.test.ts @@ -7,10 +7,12 @@ 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', () => ({ @@ -93,5 +95,7 @@ describe('getTagChangelog', () => { 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 查询失败')) }) }) From cb41d08f84cdf414098272421e0c80b93a4fec3e Mon Sep 17 00:00:00 2001 From: liweijie <674416404@qq.com> Date: Mon, 27 Jul 2026 21:53:33 +0800 Subject: [PATCH 5/5] =?UTF-8?q?feat(changelog):=20=E4=BD=BF=E7=94=A8=20PR?= =?UTF-8?q?=20=E6=A0=87=E9=A2=98=E4=BD=9C=E4=B8=BA=E5=9B=9E=E9=80=80?= =?UTF-8?q?=E6=97=A5=E5=BF=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 当 PR body 中没有有效更新日志且未显式跳过时,自动使用 PR 标题作为回退。符合规范的标题保留类型与 scope,其他标题归入 Others。 --- README.md | 2 ++ dist/index.mjs | 18 +++++++++- src/utils/common.ts | 20 ++++++++++- test/utils/tag-changelog.test.ts | 60 ++++++++++++++++++++++++++++++++ 4 files changed, 98 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 8d1ba60..ec48fee 100644 --- a/README.md +++ b/README.md @@ -296,6 +296,8 @@ release PR 打开时,FlowPilot 读取各包的 `.changelog/*.md`,按类型 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` 读写位置。 diff --git a/dist/index.mjs b/dist/index.mjs index 00aaf82..0506a0e 100644 --- a/dist/index.mjs +++ b/dist/index.mjs @@ -31389,6 +31389,14 @@ function extractTagChangelogLogs(markdown, pkgNames) { }); 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 的全部历史。 @@ -31401,7 +31409,15 @@ async function getTagChangelog(token, pkgNames, fromRef, toRef) { for (const prNumber of prNumbers) try { const prData = await getPullRequestData(prNumber); if (!isExtractPRLog(prData)) continue; - extractTagChangelogLogs(prData.body || "", pkgNames).forEach((log) => { + 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}`); diff --git a/src/utils/common.ts b/src/utils/common.ts index b9b6970..4067906 100644 --- a/src/utils/common.ts +++ b/src/utils/common.ts @@ -534,6 +534,17 @@ function extractTagChangelogLogs(markdown: string, pkgNames: string[]): string[] 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 的全部历史。 @@ -550,7 +561,14 @@ export async function getTagChangelog(token: string, pkgNames: string[], fromRef if (!isExtractPRLog(prData)) { continue } - const prLogs = extractTagChangelogLogs(prData.body || '', pkgNames) + 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}))` diff --git a/test/utils/tag-changelog.test.ts b/test/utils/tag-changelog.test.ts index b9ce71c..9b3c64a 100644 --- a/test/utils/tag-changelog.test.ts +++ b/test/utils/tag-changelog.test.ts @@ -25,6 +25,7 @@ vi.mock('../../src/utils/github', () => ({ 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, @@ -98,4 +99,63 @@ describe('getTagChangelog', () => { 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('') + }) })