diff --git a/services/winget/version.js b/services/winget/version.js new file mode 100644 index 0000000000000..043b1dc8f9283 --- /dev/null +++ b/services/winget/version.js @@ -0,0 +1,168 @@ +/** + * 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('.') + + 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 } diff --git a/services/winget/version.spec.js b/services/winget/version.spec.js new file mode 100644 index 0000000000000..6d213dc64b690 --- /dev/null +++ b/services/winget/version.spec.js @@ -0,0 +1,43 @@ +import { test, given } from 'sazerac' +import { compareVersion } 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) + }) +}) diff --git a/services/winget/winget-version.service.js b/services/winget/winget-version.service.js new file mode 100644 index 0000000000000..fc98324d395c6 --- /dev/null +++ b/services/winget/winget-version.service.js @@ -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() + +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 }) + } +} diff --git a/services/winget/winget-version.tester.js b/services/winget/winget-version.tester.js new file mode 100644 index 0000000000000..75087de4cbb58 --- /dev/null +++ b/services/winget/winget-version.tester.js @@ -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' })