feat: Parse versions from metadata links

Dependabot PRs that update a **single** dependency include version details in the commit message introduction, e.g.,
> "Bumps `<dependency>` from `<prevVersion>` to `<newVersion>`"
This is the format generated by the [`commit_message_intro`](cc4b4eaade/common/lib/dependabot/pull_request_creator/message_builder.rb (L320-L325)) method in Dependabot Core.

However, when **multiple dependencies** are updated in a single PR, this format isn't used consistently, which limits the action’s ability to extract accurate version information.

This change improves version parsing for multi-dependency PRs by introducing two additional detection strategies:

1. **YAML metadata parsing**
   Dependabot includes a YAML block in the commit message with structured details for each updated dependency:
   ```yaml
   updated-dependencies:
   - dependency-name: commons-codec:commons-codec
     dependency-version: 1.18.0
     dependency-type: direct:production
     update-type: version-update:semver-minor
     dependency-group: non-breaking
   ```
   This is the most reliable and stable source for the **new** version of each dependency, though it does **not** include the previous version.

2. **Metadata links parsing**
   In multi-dependency updates, Dependabot also appends “metadata links” with a format like:
   > "Updates `<dependencyName>` from `<prevVersion>` to `<newVersion>`"
     These lines are generated bythe [`metadata_links`](cc4b4eaade/common/lib/dependabot/pull_request_creator/message_builder.rb (L664-L678)) method and provide **both** the old and new versions.

By combining these sources, the action now supports version parsing for PRs with multiple updated dependencies—broadening its coverage and improving reliability.

Closes #402
This commit is contained in:
Piotr P. Karwasz
2025-06-23 23:47:59 +02:00
parent 496eb7a6d0
commit 60e1c69665
2 changed files with 91 additions and 5 deletions

View File

@@ -507,3 +507,54 @@ test('calculateUpdateType should handle all paths', () => {
expect(updateMetadata.calculateUpdateType('1.1.1', '1.1.2')).toEqual('version-update:semver-patch')
expect(updateMetadata.calculateUpdateType('1.1.1.1', '1.1.1.2')).toEqual('version-update:semver-patch')
})
test('it handles versions from `metadataLinks`', async () => {
const commitMessage = `Bump the non-breaking group in /log4j-parent with 2 updates
Bumps the non-breaking group in /log4j-parent with 2 updates:
Updates \`commons-codec:commons-codec\` from 1.17.0 to 1.18.0
- [Changelog](https://github.com/apache/commons-codec/blob/master/RELEASE-NOTES.txt)
- [Commits](apache/commons-codec@rel/commons-codec-1.17.0...rel/commons-codec-1.18.0)
Updates \`org.apache.commons:commons-compress\` to 1.27.1
---
updated-dependencies:
- dependency-name: commons-codec:commons-codec
- dependency-name: org.apache.commons:commons-compress
...
`
const updatedDependencies = await updateMetadata.parse(commitMessage, '', 'dependabot/maven/non-breaking-cc60d48967', '2.x')
expect(updatedDependencies).toHaveLength(2)
expect(updatedDependencies[0].dependencyName).toEqual('commons-codec:commons-codec')
expect(updatedDependencies[0].prevVersion).toEqual('1.17.0')
expect(updatedDependencies[0].newVersion).toEqual('1.18.0')
expect(updatedDependencies[1].dependencyName).toEqual('org.apache.commons:commons-compress')
expect(updatedDependencies[1].prevVersion).toEqual('')
expect(updatedDependencies[1].newVersion).toEqual('1.27.1')
})
test('it handles new versions from YAML', async () => {
const commitMessage = `Bump the non-breaking group in /log4j-parent with 2 updates
Bumps the non-breaking group in /log4j-parent with 2 updates:
---
updated-dependencies:
- dependency-name: commons-codec:commons-codec
dependency-version: 1.18.0
- dependency-name: org.apache.commons:commons-compress
dependency-version: 1.27.1
...
`
const updatedDependencies = await updateMetadata.parse(commitMessage, '', 'dependabot/maven/non-breaking-cc60d48967', '2.x')
expect(updatedDependencies).toHaveLength(2)
expect(updatedDependencies[0].dependencyName).toEqual('commons-codec:commons-codec')
expect(updatedDependencies[0].prevVersion).toEqual('')
expect(updatedDependencies[0].newVersion).toEqual('1.18.0')
expect(updatedDependencies[1].dependencyName).toEqual('org.apache.commons:commons-compress')
expect(updatedDependencies[1].prevVersion).toEqual('')
expect(updatedDependencies[1].newVersion).toEqual('1.27.1')
})

View File

@@ -6,15 +6,18 @@ export interface dependencyAlert {
cvss: number
}
export interface updatedDependency extends dependencyAlert {
interface dependencyVersions {
prevVersion: string,
newVersion: string
}
export interface updatedDependency extends dependencyAlert, dependencyVersions {
dependencyName: string,
dependencyType: string,
updateType: string,
directory: string,
packageEcosystem: string,
targetBranch: string,
prevVersion: string,
newVersion: string,
compatScore: number,
maintainerChanges: boolean,
dependencyGroup: string
@@ -83,11 +86,14 @@ export async function parse (commitMessage: string, body: string, branchName: st
const dependencyGroup = groupName?.groups?.name ?? ''
if (data['updated-dependencies']) {
const updatedVersions = parseMetadataLinks(commitMessage)
const dirname = branchNameToDirectoryName(chunks, delim, data['updated-dependencies'], dependencyGroup)
return await Promise.all(data['updated-dependencies'].map(async (dependency, index) => {
const lastVersion = index === 0 ? prev : ''
const nextVersion = index === 0 ? next : ''
const dependencyName = dependency['dependency-name']
const updatedVersion = updatedVersions.get(dependencyName)
const lastVersion = updatedVersion?.prevVersion || (index === 0 ? prev : '')
const nextVersion = dependency['dependency-version'] || updatedVersion?.newVersion || (index === 0 ? next : '')
const updateType = dependency['update-type'] || calculateUpdateType(lastVersion, nextVersion)
return {
dependencyName: dependency['dependency-name'],
@@ -110,6 +116,35 @@ export async function parse (commitMessage: string, body: string, branchName: st
return Promise.resolve([])
}
/**
* Parses the human-readable metadata links from a commit message.
* <p>
* See {@code Dependabot::PullRequestCreator::MessageBuilder#metadata_links} in the Ruby codebase for more details
* on the current format.
* </p>
* <p>
* <strong>Note:</strong> This data is only available if more than one dependency is updated in a single PR.
* </>
* @param commitMessage - The commit message containing metadata links.
* @returns A map from the name of the dependency to an updatedDependency object containing the old and new versions.
*/
function parseMetadataLinks(commitMessage: string): Map<string, dependencyVersions> {
const updates: Map<string, dependencyVersions> = new Map()
const updatesExpr: RegExp = /^Updates `(?<dependencyName>\S+)` (from (?<from>\S+) )?to (?<to>\S+)$/gm
let match: RegExpExecArray | null
while ((match = updatesExpr.exec(commitMessage)) !== null) {
const groups = match.groups
if (groups) {
const dependencyName = groups.dependencyName
updates.set(dependencyName, {
prevVersion: groups.from ?? '',
newVersion: groups.to
})
}
}
return updates
}
export function calculateUpdateType (lastVersion: string, nextVersion: string) {
if (!lastVersion || !nextVersion || lastVersion === nextVersion) {
return ''