从第三方 CHANGELOG 到 PR 变更日志:深入 Renovate 的 changelog 解析管线(以 adapter-utils.md 测试夹具为例)
【免费下载链接】renovateHome of the Renovate CLI: Cross-platform Dependency Automation by Mend.io项目地址: https://gitcode.com/GitHub_Trending/re/renovate
Renovate 在升级依赖并创建 Pull Request 时,会在 PR 正文中附带"变更日志"(changelog),帮助开发者快速判断这次升级改了什么。本文以仓库中一份真实的测试夹具 adapter-utils.md 为分析样本,结合 release-notes.ts、gitlab/index.ts 与 release-notes.spec.ts 的源码实现,完整讲解 Renovate 是如何发现、拉取、切分、匹配并清洗第三方项目的 CHANGELOG 文件,最终把指定版本的变更条目拼进 PR 正文的。读完本文,你将理解 Renovate 变更日志管线的完整调用链、标题解析与版本匹配策略,以及它如何用"真实世界样本"来驱动单元测试。
一、先认识这份文档:一份真实的 GitLab 项目 CHANGELOG 样本
adapter-utils.md 位于lib/workers/repository/update/pr/changelog/__fixtures__/目录,与angular-js.md、jest.md、js-yaml.md、yargs.md等一同作为变更日志解析的测试输入。它本身并非 Renovate 的说明文档,而是itentialopensource/adapter-utils 项目 CHANGELOG.md 的逐字副本,被用作release-notes.spec.ts中 GitLab 平台解析用例的 fixture 数据。
这份样本的独特价值在于它的"不规整性":
- 早期版本段(
# Current Version: 4.3.1及之前)是典型的Keep-a-Changelog 风格:## New Features、## Improvements、## Bug Fixes、## Deprecation、## Security分类齐全,且版本以__3.10.0 [12-05-2018]__这种加粗内联形式嵌在条目里; - 后期版本段(
## 4.33.0 [05-15-2020]起)切换为逐版本平铺的紧凑格式:每个版本一个二级标题,含日期、条目、Closes ADAPT-xxx工单引用与See merge request itentialopensource/adapter-utils!177合并请求引用。
这种"同一文件内部混用两种风格"的结构,恰好是解析器最容易出错、也最值得测试覆盖的场景。
二、样本的数据形态:Renovate 需要解析的四种结构要素
逐行阅读 adapter-utils.md,可以归纳出 Renovate 解析器必须处理的全部结构要素:
2.1 逐版本平铺段(新版格式,第 2 行起)
## 4.33.0 [05-15-2020] * add new auth, fix accept header and base path in mock Closes ADAPT-207 See merge request itentialopensource/adapter-utils!177 --- ## 4.32.3 [04-30-2020] * set username and password in token entitypath Closes ADAPT-198 See merge request itentialopensource/adapter-utils!176这段结构包含:
- 版本标题:
## <版本号> [YYYY-MM-DD],版本号与日期之间是空格而非链接语法; - 条目列表:以
*开头的无序列表,描述本次变更内容; - 工单引用:
Closes ADAPT-207形式的单行文本; - 合并请求引用:
See merge request itentialopensource/adapter-utils!177; - 分隔线:
---将相邻版本隔开。
fixture 覆盖了从4.33.0 [05-15-2020]到4.24.0 [11-01-2019]的完整平铺段,每一条都保持"标题 + 条目 + 引用 + 分隔线"的统一骨架,例如4.30.14记录"add in double check for starting slashes in the path",4.17.2记录"update regex for , and ()",4.9.3记录"add filter to response after field is found, based on respFilter in schema"。
2.2 分类标题段(旧版格式)
从# Current Version: 4.3.1 [03-26-2019]开始,样本切换到另一种组织方式:
# Current Version: 4.3.1 [03-26-2019] ## New Features ## Improvements ## Bug Fixes ## Deprecation ## Security在每个分类下,版本信息以__<版本> [日期]__内联加粗形式出现在条目开头,例如:
* __3.10.0 [12-05-2018]__ - New methods have been add to: ... * __3.9.0 [12-04-2018]__ - The external name on schemas can now be at the same level or lower * __2.1.0 [08-17-2018]__ - These libraries now support token re-use and expiration ...这一段的"版本号不在标题里,而在正文条目里"的特征,正是 Renovate 需要在正文中搜索版本号的原因(见 4.3 节)。
2.3 大版本发布列表(如 2.0.0)
样本在## New Features下还包含一次大版本集中发布说明(2.0.0 [08-13-2018]),一口气列出 PH-16044、PH-16024、PH-16125、PH-15075、PH-14311、PH-16053、PH-16141、PH-16239、PH-16268、PH-15718 共 10 项改动,涵盖通用调用、mock 数据、代理能力、Base64 认证、加解密、action 数组化、entitypath 语法变更等。
2.4 外层壳:<a name>锚点与文末#\n##哨兵
源码 gitlab/index.ts 在取回 changelog 原文后,会执行:
const changelogMd = `${fileRes.body}\n#\n##`;即在文末追加\n#\n##两个伪标题作为哨兵,保证最后一节之后一定存在更低层级的标题,使切分逻辑(见 4.1 节)能正确闭合最后一段。同时 release-notes.ts 会先用正则剔除 Keep-a-Changelog 常见的<a name="..."></a>锚点行,避免其干扰标题切分。
三、前置阶段:Renovate 如何发现并拉取 CHANGELOG 文件
在解析任何内容之前,Renovate 必须先回答两个问题:changelog 文件在哪里?以及要不要拉取它?
3.1 跳过名单与缓存
release-notes.ts 定义了repositoriesToSkipMdFetching,当前包含facebook/react-native与react/react-native——这两个仓库被显式跳过 MD 抓取,回退到 GitHub release 接口(shouldSkipChangelogMd,见 release-notes.ts)。
文件查找结果还带有两层缓存:getReleaseNotesMdFile使用内存缓存(memCache),缓存键为getReleaseNotesMdFile@v2-<repository>-<sourceDirectory>-<apiBaseUrl>(release-notes.ts);最终解析结果则进入changelog-<platform>-notes@v2命名空间的包缓存(release-notes.ts),缓存时长由releaseNotesCacheMinutes决定:发布不足一周缓存 55 分钟、不足半年约 1 天、更久约 10 天。
3.2 GitLab 侧的文件定位
对于 GitLab 仓库,gitlab/index.ts 的getReleaseNotesMd按以下顺序工作:
- 调用
GET /projects/<url编码仓库>/repository/tree?per_page=100(可选&path=<sourceDirectory>)列出仓库树,并开启分页(paginate: true); - 从返回的 tree 节点中过滤出
type === 'blob'的文件; - 用
changelog-filename-regex正则匹配文件名,筛出形如CHANGELOG.md、CHANGELOG、CHANGELOG.json的候选; - 若有多个候选,用 common.ts 的
compareChangelogFilePath排序:优先.md/.markdown/.mkd,其次.txt/.text,其余类型垫底——这正是为了避免出现CHANGELOG.json而错过CHANGELOG.md的问题; - 取排序后第一个文件,调用
GET /projects/<仓库>/repository/blobs/<blob_id>/raw拉取原始文本; - 在文末拼接
\n#\n##哨兵后返回{ changelogFile, changelogMd }。
adapter-utils.md这个 fixture 的取值场景,正是模拟上述第 5 步拿到的原始内容。
四、核心解析:sectionize 切分、标题匹配与正文提取
这是整条管线的灵魂,实现在 release-notes.ts 的getReleaseNotesMd中。
4.1 按标题级别切分:sectionize
function sectionize(text: string, level: number): string[] { const tokens = markdown.parse(text, {}); tokens.forEach((token) => { if (token.type === 'heading_open') { const lev = +token.tag.substring(1); if (lev <= level) { sections.push([lev, token.map![0]]); } } }); sections.push([-1, lines.length]); // 取出所有恰好等于 level 的节 }解析器使用markdown-it(仅启用heading、lheading、fence三个规则,见 release-notes.ts)把整个 changelog 文本 token 化,然后从 level 1 到 level 7 依次尝试:只要某个级别能切出至少 2 节,就按该级别遍历每个 section。这样无论样本用的是#(如# Current Version: 4.3.1)还是##(如## 4.33.0 [05-15-2020]),都能被正确切分。
对adapter-utils.md而言,顶层# Current Version: 4.3.1与# Previous Version: 1.3.2构成 level 1 的两节;而平铺段的## 4.33.0 [05-15-2020]系列则会在 level 2 被切出。\n#\n##哨兵保证了最后一个版本节(4.24.0)之后有更低级标题用于闭合。
4.2 标题内版本匹配:Look for version in title
对每个 section,解析器做三件事:
const deParenthesizedSection = section.replace(regEx(/[[\]()]/g), ' '); const [heading] = deParenthesizedSection.split(newlineRegex); const title = heading.replace(regEx(/^\s*#*\s*/), '').split(' ').filter(isTruthy); const body = section.replace(regEx(/.*?\n(?:-{3,}\n)?/), '').trim();- 去括号化:把所有
[、]、(、)替换为空格,避免链接语法干扰分词; - 取标题分词:剥掉开头的
#,按空格拆词; - 去正文前缀:用
.*?\n(?:-{3,}\n)?把标题行及紧随其后的---分隔线从正文中剥离。
随后遍历标题中的每个词,只要某个词包含目标版本号且不是 URL,即命中:
if (word.includes(version) && !isHttpUrl(word)) { return { body: await linkifyBody(project, body), url, notesSourceUrl }; }对## 4.33.0 [05-15-2020]而言,标题分词后是['4.33.0', '[05-15-2020]'](去括号后),目标版本4.33.0直接命中,正文即* add new auth, fix accept header and base path in mock\n\nCloses ADAPT-207\n\nSee merge request itentialopensource/adapter-utils!177。
4.3 正文内版本匹配:monorepo 与内联版本场景
fixture 中的旧版格式(__3.10.0 [12-05-2018]__内联加粗、# Current Version: 4.3.1这类不含日期版本对)无法靠标题命中。解析器为此实现了第二套策略(release-notes.ts):
- 标题中必须含
YYYY-MM-DD日期格式(releasesRegex),规避没有日期的普通标题; - 正文中必须同时出现
packageName与目标version(适用于 monorepo 中包名与版本散落在条目里的情况); - 逐行检查时跳过 Markdown 链接引用定义(
[1.2.3]: https://…形式,即 Keep-a-Changelog 文末常见的 compare 链接表),否则每一行都会"伪命中"; - 同样排除含 URL 的行。
测试用例parses when version contained in the body 0.14.0与ignores trailing link reference definitions when searching body(见 release-notes.spec.ts)正是为验证这套策略而设。
五、输出后处理:massageBody、linkify 与 URL 生成
命中后,返回结构遵循 types.ts 中的ChangeLogNotes接口:body、url、notesSourceUrl。其中:
notesSourceUrl由source.getNotesSourceUrl(baseUrl, repository, changelogFile)生成。对 GitLab,gitlab/source.ts 的GitLabChangeLogSource以gitlab-tags为 datasource,getAPIBaseUrl返回<baseUrl>api/v4/,最终拼接出如<baseUrl>itentialopensource/adapter-utils/blob/HEAD/CHANGELOG.md的源文件地址;url是锚点链接,由getReleaseNotesMdAnchorUrl(notesSourceUrl, parenthesizedHeading)对原始标题(保留括号)做 slug 化生成,如#4330-05-15-2020。测试中对4.33.0的断言正是…/CHANGELOG.md#4330-05-15-2020;body经过 massageBody 清洗:统一\r\n、剔除 semantic-release 的<a name>行与 compare 链接、把#/##/####逐级降为###/####/#####(代码块内的#会被保护不处理),最后 trim 空白。
最后linkifyBody会把正文中的仓库引用(如itentialopensource/adapter-utils!177这类 merge request 引用)转成可点击链接。在getReleaseNotes路径(GitLab release API 而非 MD 文件)中,releaseNotesResult会为 GitLab 拼接<baseUrl><repository>/tags/<tag>作为 URL,并对非https://gitlab.com/的实例执行同样的 linkify(release-notes.ts)。
六、测试如何验证:release-notes.spec.ts 中的适配器样本用例
fixture 的最终价值落在测试上。在 release-notes.spec.ts 的parses adapter-utils 4.33.0用例中:
- 用
httpMock模拟 GitLab 的两个接口:GET /api/v4/projects/itentialopensource%2Fadapter-utils/repository/tree?per_page=100返回gitlabTreeResponse,GET …/repository/blobs/abcd/raw返回本 fixture 的内容; - 以
repository: 'itentialopensource/adapter-utils'、version: '4.33.0'、gitRef: '4.33.0'调用getReleaseNotesMd; - 断言结果:
notesSourceUrl指向…/blob/HEAD/CHANGELOG.md,url带#4330-05-15-2020锚点;- 正文以
- add new auth, fix accept header and base path in mock\n开头(注意:massageBody的降级规则把原文档中的## 4.33.0节标题降为####后从正文剥离,正文从第一条*条目开始,且*被linkify处理为-列表项); - 正文包含
Closes ADAPT-207与See merge request itentialopensource/adapter-utils!177; - 正文以
***结尾; - 相邻版本不泄漏:正文既不包含
ADAPT-198,也不包含set username and password in token entitypath——这正是sectionize精确切分的直接验证。
同文件还包含handles gitlab sourceDirectory用例(release-notes.spec.ts):把 tree 响应中的每个文件路径加上packages/foo/前缀后,同一份 fixture 应被解析出notesSourceUrl指向…/blob/HEAD/packages/foo/CHANGELOG.md,验证了sourceDirectory场景下"同一 changelog 内容、不同来源 URL"的正确性。
七、更完整的调用链:从版本列表到 PR 正文
将上述环节串起来,完整的调用链是:
addReleaseNotes(release-notes.ts)遍历每个待展示的版本;- 先查包缓存,未命中则依次尝试
getReleaseNotesMd(解析 CHANGELOG 文件)→getReleaseNotes(GitLab/GitHub release API)→ 退化为 compare URL; - 缓存时长按版本发布日期动态决定;
- 当
config.fetchChangeLogs === 'pr'时,会受平台 PR 正文长度上限约束:累计正文达到platform.maxBodyLength()后停止继续抓取更旧版本(因为按从新到旧顺序,最新变更始终可见)。
adapter-utils.md作为 fixture,精确覆盖了第 2 步中"GitLab + 头部平铺格式 + 旧版分类格式 + 文末哨兵 + 合并请求引用"这一组合,是 Renovate 保证不同格式 changelog 都能被正确解析的基石性测试样本。
八、小结与延伸阅读
围绕 adapter-utils.md 这 1000 余行的真实 changelog 样本,可以总结出 Renovate 变更日志解析的三条设计原则:
- 格式无关:通过 1~7 级标题逐级探测切分,兼容 Keep-a-Changelog、逐版本平铺、内联加粗版本等异构风格;
- 精确不泄漏:
sectionize+ 哨兵标题 + 链接引用定义过滤,确保只提取目标版本、不污染相邻版本; - 真实样本驱动:测试直接用真实项目的 changelog 全文,让解析器始终面对"现实世界的脏数据"。
如果你希望进一步深入,可以从以下文件继续阅读:
- 解析与清洗主逻辑:release-notes.ts
- GitLab 侧文件定位与 release 列表:gitlab/index.ts
- changelog 文件优先级排序:common.ts
- 数据结构定义:types.ts
- 覆盖 adapter-utils 样本的测试用例:release-notes.spec.ts
- 其他同类测试样本:
jest.md、yargs.md、js-yaml.md、gitter-webapp.md、angular-js.md(均在fixtures目录下)
【免费下载链接】renovateHome of the Renovate CLI: Cross-platform Dependency Automation by Mend.io项目地址: https://gitcode.com/GitHub_Trending/re/renovate
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考