Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

add [WingetVersion] Badge #10245

Open
wants to merge 5 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
144 changes: 144 additions & 0 deletions services/winget/version.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
/**
* 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
const v1Trimmed = trimPrefix(v1)
const v2Trimmed = trimPrefix(v2)

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

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
}

// ignore .0s at the end
if (parts1.length > parts2.length) {
for (let i = parts2.length; i < parts1.length; i++) {
if (parts1[i].trim() !== '0') {
return 1
}
}
} else if (parts1.length < parts2.length) {
for (let i = parts1.length; i < parts2.length; i++) {
if (parts2[i].trim() !== '0') {
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
}

/**
* 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 and pre-release versions are excluded.
* latest(['1.2.3', '1.2.4', '1.3', '2.0']) // returns '2.0' because pre-release versions are included but none of them are present in the array.
* latest(['1.2.3', '1.2.4', '1.3-alpha', '2.0-beta']) // returns '1.2.4' because pre-release versions are excluded and it is the largest version number among the remaining ones.
*/
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 }
28 changes: 28 additions & 0 deletions services/winget/version.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { test, given } from 'sazerac'
import { compareVersion } from './version.js'

describe('Winget Version helpers', function () {
test(compareVersion, () => {
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)
})
})
93 changes: 93 additions & 0 deletions services/winget/winget-version.service.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
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(),
}),
),
})
.allow(null)
.required(),
}).required(),
}).required(),
}).required()
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍


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
}
}
}
}
}
`,
variables: { expression },
schema,
transformErrors,
})
}

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

return renderVersionBadge({ version })
}
}
73 changes: 73 additions & 0 deletions services/winget/winget-version.tester.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
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',
},
{
type: 'tree',
name: '0.1101.416.0',
},
{
type: 'tree',
name: '0.1201.442.0',
},
{
type: 'tree',
name: '0.137.141.0',
},
{
type: 'tree',
name: '0.200.170.0',
},
{
type: 'tree',
name: '0.503.261.0',
},
{
type: 'tree',
name: '0.601.285.0',
},
{
type: 'tree',
name: '0.601.297.0',
},
{
type: 'tree',
name: '0.701.323.0',
},
{
type: 'tree',
name: '0.801.344.0',
},
],
},
},
},
}),
)
.get('/Microsoft.DevHome.json')
.expectBadge({ label: 'winget', message: 'v0.1201.442.0' })
Loading