diff --git a/services/winget/version.js b/services/winget/version.js new file mode 100644 index 0000000000..9f7c776346 --- /dev/null +++ b/services/winget/version.js @@ -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 } diff --git a/services/winget/version.spec.js b/services/winget/version.spec.js new file mode 100644 index 0000000000..d4449b8767 --- /dev/null +++ b/services/winget/version.spec.js @@ -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') + }) +}) diff --git a/services/winget/winget-version.service.js b/services/winget/winget-version.service.js new file mode 100644 index 0000000000..15565a76d6 --- /dev/null +++ b/services/winget/winget-version.service.js @@ -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 }) + } +} diff --git a/services/winget/winget-version.tester.js b/services/winget/winget-version.tester.js new file mode 100644 index 0000000000..0bc5bf6c6b --- /dev/null +++ b/services/winget/winget-version.tester.js @@ -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' })