1
0
mirror of https://github.com/badges/shields.git synced 2025-04-18 19:44:04 +03:00

add [WingetVersion] Badge (#10245)

* 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 <git@chris-shaw.dev>
This commit is contained in:
anatawa12 2024-11-05 04:05:32 +09:00 committed by GitHub
parent 4ec62fa445
commit 00d72da97e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 692 additions and 0 deletions

172
services/winget/version.js Normal file
View File

@ -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 }

View File

@ -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')
})
})

View File

@ -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 })
}
}

View File

@ -0,0 +1,343 @@
import { isVPlusDottedVersionNClauses } from '../test-validators.js'
import { createServiceTester } from '../tester.js'
export const t = await createServiceTester()
// basic test
t.create('gets the package version of WSL')
.get('/Microsoft.WSL.json')
.expectBadge({ label: 'winget', message: isVPlusDottedVersionNClauses })
// test more than one dots
t.create('gets the package version of .NET 8')
.get('/Microsoft.DotNet.SDK.8.json')
.expectBadge({ label: 'winget', message: isVPlusDottedVersionNClauses })
// test sort based on dotted version order instead of ASCII
t.create('gets the latest version')
.intercept(nock =>
nock('https://api.github.com/')
.post('/graphql')
.reply(200, {
data: {
repository: {
object: {
entries: [
{
type: 'tree',
name: '0.1001.389.0',
object: {
entries: [
{
type: 'blob',
name: 'Microsoft.DevHome.installer.yaml',
},
{
type: 'blob',
name: 'Microsoft.DevHome.locale.en-US.yaml',
},
{
type: 'blob',
name: 'Microsoft.DevHome.yaml',
},
],
},
},
{
type: 'tree',
name: '0.1101.416.0',
object: {
entries: [
{
type: 'blob',
name: 'Microsoft.DevHome.installer.yaml',
},
{
type: 'blob',
name: 'Microsoft.DevHome.locale.en-US.yaml',
},
{
type: 'blob',
name: 'Microsoft.DevHome.yaml',
},
],
},
},
{
type: 'tree',
name: '0.1201.442.0',
object: {
entries: [
{
type: 'blob',
name: 'Microsoft.DevHome.installer.yaml',
},
{
type: 'blob',
name: 'Microsoft.DevHome.locale.en-US.yaml',
},
{
type: 'blob',
name: 'Microsoft.DevHome.yaml',
},
],
},
},
{
type: 'tree',
name: '0.137.141.0',
object: {
entries: [
{
type: 'blob',
name: 'Microsoft.DevHome.installer.yaml',
},
{
type: 'blob',
name: 'Microsoft.DevHome.locale.en-US.yaml',
},
{
type: 'blob',
name: 'Microsoft.DevHome.yaml',
},
],
},
},
{
type: 'tree',
name: '0.200.170.0',
object: {
entries: [
{
type: 'blob',
name: 'Microsoft.DevHome.installer.yaml',
},
{
type: 'blob',
name: 'Microsoft.DevHome.locale.en-US.yaml',
},
{
type: 'blob',
name: 'Microsoft.DevHome.yaml',
},
],
},
},
{
type: 'tree',
name: '0.503.261.0',
object: {
entries: [
{
type: 'blob',
name: 'Microsoft.DevHome.installer.yaml',
},
{
type: 'blob',
name: 'Microsoft.DevHome.locale.en-US.yaml',
},
{
type: 'blob',
name: 'Microsoft.DevHome.yaml',
},
],
},
},
{
type: 'tree',
name: '0.601.285.0',
object: {
entries: [
{
type: 'blob',
name: 'Microsoft.DevHome.installer.yaml',
},
{
type: 'blob',
name: 'Microsoft.DevHome.locale.en-US.yaml',
},
{
type: 'blob',
name: 'Microsoft.DevHome.yaml',
},
],
},
},
{
type: 'tree',
name: '0.601.297.0',
object: {
entries: [
{
type: 'blob',
name: 'Microsoft.DevHome.installer.yaml',
},
{
type: 'blob',
name: 'Microsoft.DevHome.locale.en-US.yaml',
},
{
type: 'blob',
name: 'Microsoft.DevHome.yaml',
},
],
},
},
{
type: 'tree',
name: '0.701.323.0',
object: {
entries: [
{
type: 'blob',
name: 'Microsoft.DevHome.installer.yaml',
},
{
type: 'blob',
name: 'Microsoft.DevHome.locale.en-US.yaml',
},
{
type: 'blob',
name: 'Microsoft.DevHome.yaml',
},
],
},
},
{
type: 'tree',
name: '0.801.344.0',
object: {
entries: [
{
type: 'blob',
name: 'Microsoft.DevHome.installer.yaml',
},
{
type: 'blob',
name: 'Microsoft.DevHome.locale.en-US.yaml',
},
{
type: 'blob',
name: 'Microsoft.DevHome.yaml',
},
],
},
},
],
},
},
},
}),
)
.get('/Microsoft.DevHome.json')
.expectBadge({ label: 'winget', message: 'v0.1201.442.0' })
// Both 'Some.Package' and 'Some.Package.Sub' are present in the response.
// We should ignore 'Some.Package.Sub' in response to 'Some.Package' request.
// In this test case, Canonical.Ubuntu.2404 is present, but it should not be treated as Canonical.Ubuntu version 2404.
t.create('do not pick sub-package as version')
.intercept(nock =>
nock('https://api.github.com/')
.post('/graphql')
.reply(200, {
data: {
repository: {
object: {
entries: [
{
type: 'blob',
name: '.validation',
object: {},
},
{
type: 'tree',
name: '1804',
object: {
entries: [
{
type: 'tree',
name: '1804.6.4.0',
},
],
},
},
{
type: 'tree',
name: '2004',
object: {
entries: [
{
type: 'tree',
name: '2004.6.16.0',
},
],
},
},
{
type: 'tree',
name: '2204.1.8.0',
object: {
entries: [
{
type: 'blob',
name: 'Canonical.Ubuntu.installer.yaml',
},
{
type: 'blob',
name: 'Canonical.Ubuntu.locale.en-US.yaml',
},
{
type: 'blob',
name: 'Canonical.Ubuntu.locale.zh-CN.yaml',
},
{
type: 'blob',
name: 'Canonical.Ubuntu.yaml',
},
],
},
},
{
type: 'tree',
name: '2204',
object: {
entries: [
{
type: 'blob',
name: '.validation',
},
{
type: 'tree',
name: '2204.0.10.0',
},
{
type: 'tree',
name: '2204.2.47.0',
},
],
},
},
{
type: 'tree',
name: '2404',
object: {
entries: [
{
type: 'blob',
name: '.validation',
},
{
type: 'tree',
name: '2404.0.5.0',
},
],
},
},
],
},
},
},
}),
)
.get('/Canonical.Ubuntu.json')
.expectBadge({ label: 'winget', message: 'v2204.1.8.0' })