Skip to content

Commit

Permalink
add [WingetVersion] Badge (#10245)
Browse files Browse the repository at this point in the history
* feat: add winget version badge

* chore: accept dotted path instead of slashed

* test: add test for winget-version

* fix: remove debug code

* chore: use winget-specific version compare algorithm

* fix: support latest and unknown

* fix(winget/version): trailing '.0' handling is incorrect

* fix(winget/version): latest returns last newest version instead of the first newest version

* fix(winget/version): confusing subpackage and version name

* fix(winget/version): example for latest is incorrect

* add a couple of extra test cases for latest()

---------

Co-authored-by: chris48s <[email protected]>
  • Loading branch information
anatawa12 and chris48s authored Nov 4, 2024
1 parent 4ec62fa commit 00d72da
Show file tree
Hide file tree
Showing 4 changed files with 692 additions and 0 deletions.
172 changes: 172 additions & 0 deletions services/winget/version.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
/**
* Comparing versions with winget's version comparator.
*
* See https://github.com/microsoft/winget-cli/blob/ae566c7bf21cfcc75be7ec30e4036a30eede8396/src/AppInstallerSharedLib/Versions.cpp for original implementation.
*
* @module
*/

/**
* Compares two strings representing version numbers lexicographically and returns an integer value.
*
* @param {string} v1 - The first version to compare
* @param {string} v2 - The second version to compare
* @returns {number} -1 if v1 is smaller than v2, 1 if v1 is larger than v2, 0 if v1 and v2 are equal
* @example
* compareVersion('1.2.3', '1.2.4') // returns -1 because numeric part of first version is smaller than the numeric part of second version.
*/
function compareVersion(v1, v2) {
// https://github.com/microsoft/winget-cli/blob/ae566c7bf21cfcc75be7ec30e4036a30eede8396/src/AppInstallerSharedLib/Versions.cpp#L109-L173
// This implementation does not parse s_Approximate_Greater_Than
// and s_Approximate_Less_Than since they won't appear in directory name (package version parsed by shields.io)
const v1Trimmed = trimPrefix(v1)
const v2Trimmed = trimPrefix(v2)

const v1Latest = v1Trimmed.trim().toLowerCase() === 'latest'
const v2Latest = v2Trimmed.trim().toLowerCase() === 'latest'

if (v1Latest && v2Latest) {
return 0
} else if (v1Latest) {
return 1
} else if (v2Latest) {
return -1
}

const v1Unknown = v1Trimmed.trim().toLowerCase() === 'unknown'
const v2Unknown = v2Trimmed.trim().toLowerCase() === 'unknown'

if (v1Unknown && v2Unknown) {
return 0
} else if (v1Unknown) {
return -1
} else if (v2Unknown) {
return 1
}

const parts1 = v1Trimmed.split('.')
const parts2 = v2Trimmed.split('.')

trimLastZeros(parts1)
trimLastZeros(parts2)

for (let i = 0; i < Math.min(parts1.length, parts2.length); i++) {
const part1 = parts1[i]
const part2 = parts2[i]

const compare = compareVersionPart(part1, part2)
if (compare !== 0) {
return compare
}
}

if (parts1.length === parts2.length) {
return 0
}

if (parts1.length > parts2.length) {
return 1
} else if (parts1.length < parts2.length) {
return -1
}

return 0
}

/**
* Removes all leading non-digit characters from a version number string
* if there is a digit before the split character, or no split characters exist.
*
* @param {string} version The version number string to trim
* @returns {string} The version number string with all leading non-digit characters removed
*/
function trimPrefix(version) {
// https://github.com/microsoft/winget-cli/blob/ae566c7bf21cfcc75be7ec30e4036a30eede8396/src/AppInstallerSharedLib/Versions.cpp#L66
// If there is a digit before the split character, or no split characters exist, trim off all leading non-digit characters

const digitPos = version.match(/(\d.*)/)
const splitPos = version.match(/\./)
if (digitPos && (splitPos == null || digitPos.index < splitPos.index)) {
// there is digit before the split character so strip off all leading non-digit characters
return version.slice(digitPos.index)
}
return version
}

/**
* Removes all trailing zeros from a version number part array.
*
* @param {string[]} parts - parts
*/
function trimLastZeros(parts) {
while (parts.length > 1 && parts[parts.length - 1].trim() === '0') {
parts.pop()
}
}

/**
* Compares two strings representing version number parts lexicographically and returns an integer value.
*
* @param {string} part1 - The first version part to compare
* @param {string} part2 - The second version part to compare
* @returns {number} -1 if part1 is smaller than part2, 1 if part1 is larger than part2, 0 if part1 and part2 are equal
* @example
* compareVersionPart('3', '4') // returns -1 because numeric part of first part is smaller than the numeric part of second part.
*/
function compareVersionPart(part1, part2) {
// https://github.com/microsoft/winget-cli/blob/ae566c7bf21cfcc75be7ec30e4036a30eede8396/src/AppInstallerSharedLib/Versions.cpp#L324-L352
const [, numericString1, other1] = part1.trim().match(/^(\d*)(.*)$/)
const [, numericString2, other2] = part2.trim().match(/^(\d*)(.*)$/)
const numeric1 = parseInt(numericString1 || '0', 10)
const numeric2 = parseInt(numericString2 || '0', 10)

if (numeric1 < numeric2) {
return -1
} else if (numeric1 > numeric2) {
return 1
}
// numeric1 === numeric2

const otherFolded1 = (other1 ?? '').toLowerCase()
const otherFolded2 = (other2 ?? '').toLowerCase()

if (otherFolded1.length !== 0 && otherFolded2.length === 0) {
return -1
} else if (otherFolded1.length === 0 && otherFolded2.length !== 0) {
return 1
}

if (otherFolded1 < otherFolded2) {
return -1
} else if (otherFolded1 > otherFolded2) {
return 1
}

return 0
}

/**
* Finds the largest version number lexicographically from an array of strings representing version numbers and returns it as a string.
*
* @param {string[]} versions - The array of version numbers to compare
* @returns {string|undefined} The largest version number as a string, or undefined if the array is empty
* @example
* latest(['1.2.3', '1.2.4', '1.3', '2.0']) // returns '2.0' because it is the largest version number.
* latest(['1.2.3', '1.2.4', '1.3-alpha', '2.0-beta']) // returns '2.0-beta'. there is no special handling for pre-release versions.
*/
function latest(versions) {
const len = versions.length
if (len === 0) {
return
}

let version = versions[0]
for (let i = 1; i < len; i++) {
if (compareVersion(version, versions[i]) <= 0) {
version = versions[i]
}
}
return version
}

export { latest, compareVersion }
57 changes: 57 additions & 0 deletions services/winget/version.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { test, given } from 'sazerac'
import { compareVersion, latest } from './version.js'

describe('Winget Version helpers', function () {
test(compareVersion, () => {
// basic compare
// https://github.com/microsoft/winget-cli/blob/43425fe97d237e03026fca4530dbc422ab445595/src/AppInstallerCLITests/Versions.cpp#L147
given('1', '2').expect(-1)
given('1.0.0', '2.0.0').expect(-1)
given('0.0.1', '0.0.2').expect(-1)
given('0.0.1-alpha', '0.0.2-alpha').expect(-1)
given('0.0.1-beta', '0.0.2-alpha').expect(-1)
given('0.0.1-beta', '0.0.2-alpha').expect(-1)
given('13.9.8', '14.1').expect(-1)

given('1.0', '1.0.0').expect(0)

// Ensure whitespace doesn't affect equality
given('1.0', '1.0 ').expect(0)
given('1.0', '1. 0').expect(0)

// Ensure versions with preambles are sorted correctly
given('1.0', 'Version 1.0').expect(0)
given('foo1', 'bar1').expect(0)
given('v0.0.1', '0.0.2').expect(-1)
given('v0.0.1', 'v0.0.2').expect(-1)
given('1.a2', '1.b1').expect(-1)
given('alpha', 'beta').expect(-1)

// latest
// https://github.com/microsoft/winget-cli/blob/43425fe97d237e03026fca4530dbc422ab445595/src/AppInstallerCLITests/Versions.cpp#L217
given('1.0', 'latest').expect(-1)
given('100', 'latest').expect(-1)
given('943849587389754876.1', 'latest').expect(-1)
given('latest', 'LATEST').expect(0)

// unknown
// https://github.com/microsoft/winget-cli/blob/43425fe97d237e03026fca4530dbc422ab445595/src/AppInstallerCLITests/Versions.cpp#L231
given('unknown', '1.0').expect(-1)
given('unknown', '1.fork').expect(-1)
given('unknown', 'UNKNOWN').expect(0)

// porting failure tests
// https://github.com/badges/shields/pull/10245#discussion_r1817931237
// trailing .0 and .0-beta
given('1.6.0', '1.6.0-beta.98').expect(-1)
})

test(latest, () => {
given(['1.2.3', '1.2.4', '2.0', '1.3.9.1']).expect('2.0')
given(['1.2.3', '1.2.4', '2.0-beta', '1.3-alpha']).expect('2.0-beta')

// compareVersion('3.1.1.0', '3.1.1') == 0, so It's free to choose any of them.
// I don't know why but it looks winget registry uses last newest version.
given(['3.1.1.0', '3.1.1']).expect('3.1.1')
})
})
120 changes: 120 additions & 0 deletions services/winget/winget-version.service.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import Joi from 'joi'
import gql from 'graphql-tag'
import { renderVersionBadge } from '../version.js'
import { InvalidParameter, pathParam } from '../index.js'
import { GithubAuthV4Service } from '../github/github-auth-service.js'
import { transformErrors } from '../github/github-helpers.js'
import { latest } from './version.js'

const schema = Joi.object({
data: Joi.object({
repository: Joi.object({
object: Joi.object({
entries: Joi.array().items(
Joi.object({
type: Joi.string().required(),
name: Joi.string().required(),
object: Joi.object({
entries: Joi.array().items(
Joi.object({
type: Joi.string().required(),
name: Joi.string().required(),
}),
),
}).required(),
}),
),
})
.allow(null)
.required(),
}).required(),
}).required(),
}).required()

export default class WingetVersion extends GithubAuthV4Service {
static category = 'version'

static route = {
base: 'winget/v',
pattern: ':name',
}

static openApi = {
'/winget/v/{name}': {
get: {
summary: 'WinGet Package Version',
description: 'WinGet Community Repository',
parameters: [
pathParam({
name: 'name',
example: 'Microsoft.WSL',
}),
],
},
},
}

static defaultBadgeData = {
label: 'winget',
}

async fetch({ name }) {
const nameFirstLower = name[0].toLowerCase()
const nameSlashed = name.replaceAll('.', '/')
const path = `manifests/${nameFirstLower}/${nameSlashed}`
const expression = `HEAD:${path}`
return this._requestGraphql({
query: gql`
query RepoFiles($expression: String!) {
repository(owner: "microsoft", name: "winget-pkgs") {
object(expression: $expression) {
... on Tree {
entries {
type
name
object {
... on Tree {
entries {
type
name
}
}
}
}
}
}
}
}
`,
variables: { expression },
schema,
transformErrors,
})
}

async handle({ name }) {
const json = await this.fetch({ name })
if (json.data.repository.object?.entries == null) {
throw new InvalidParameter({
prettyMessage: 'package not found',
})
}
const entries = json.data.repository.object.entries
const directories = entries.filter(entry => entry.type === 'tree')
const versionDirs = directories.filter(dir =>
dir.object.entries.some(
file => file.type === 'blob' && file.name === `${name}.yaml`,
),
)
const versions = versionDirs.map(dir => dir.name)
const version = latest(versions)

if (version == null) {
throw new InvalidParameter({
prettyMessage: 'no versions found',
})
}

return renderVersionBadge({ version })
}
}
Loading

0 comments on commit 00d72da

Please sign in to comment.