From 7a22dbc81550d247bd918e33505db3f2d594b650 Mon Sep 17 00:00:00 2001 From: anatawa12 Date: Tue, 11 Jun 2024 21:34:30 +0900 Subject: [PATCH 1/6] feat: add winget version badge --- services/winget/winget-version.service.js | 100 ++++++++++++++++++++++ 1 file changed, 100 insertions(+) create mode 100644 services/winget/winget-version.service.js diff --git a/services/winget/winget-version.service.js b/services/winget/winget-version.service.js new file mode 100644 index 0000000000000..df1fc0c05b121 --- /dev/null +++ b/services/winget/winget-version.service.js @@ -0,0 +1,100 @@ +import Joi from 'joi' +import gql from 'graphql-tag' +import { latest, 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' + +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: ':owner/:name', + } + + static openApi = { + '/winget/v/{owner}/{name}': { + get: { + summary: 'WinGet Package Version', + description: 'WinGet Community Repository', + parameters: [ + pathParam({ + name: 'owner', + example: 'Microsoft', + }), + pathParam({ + name: 'name', + example: 'WSL', + }), + ], + }, + }, + } + + static defaultBadgeData = { + label: 'winget', + } + + async fetch({ owner, name }) { + const ownerFirst = owner[0].toLowerCase() + const path = `manifests/${ownerFirst}/${owner}/${name}` + 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({ owner, name }) { + try { + const json = await this.fetch({ owner, 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 }) + } catch (e) { + console.log(e) + throw e + } + } +} From f71b6b806d8dc87ff298f80a206150a4735c3afb Mon Sep 17 00:00:00 2001 From: anatawa12 Date: Wed, 12 Jun 2024 12:30:45 +0900 Subject: [PATCH 2/6] chore: accept dotted path instead of slashed --- services/winget/winget-version.service.js | 21 +++++++++------------ 1 file changed, 9 insertions(+), 12 deletions(-) diff --git a/services/winget/winget-version.service.js b/services/winget/winget-version.service.js index df1fc0c05b121..add8962d8286e 100644 --- a/services/winget/winget-version.service.js +++ b/services/winget/winget-version.service.js @@ -27,22 +27,18 @@ export default class WingetVersion extends GithubAuthV4Service { static route = { base: 'winget/v', - pattern: ':owner/:name', + pattern: ':name', } static openApi = { - '/winget/v/{owner}/{name}': { + '/winget/v/{name}': { get: { summary: 'WinGet Package Version', description: 'WinGet Community Repository', parameters: [ - pathParam({ - name: 'owner', - example: 'Microsoft', - }), pathParam({ name: 'name', - example: 'WSL', + example: 'Microsoft.WSL', }), ], }, @@ -53,9 +49,10 @@ export default class WingetVersion extends GithubAuthV4Service { label: 'winget', } - async fetch({ owner, name }) { - const ownerFirst = owner[0].toLowerCase() - const path = `manifests/${ownerFirst}/${owner}/${name}` + 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` @@ -78,9 +75,9 @@ export default class WingetVersion extends GithubAuthV4Service { }) } - async handle({ owner, name }) { + async handle({ name }) { try { - const json = await this.fetch({ owner, name }) + const json = await this.fetch({ name }) if (json.data.repository.object === null) { throw new InvalidParameter({ prettyMessage: 'package not found', From d3876310a5da8daf17297fc3396558d10a01be56 Mon Sep 17 00:00:00 2001 From: anatawa12 Date: Wed, 12 Jun 2024 12:40:02 +0900 Subject: [PATCH 3/6] test: add test for winget-version --- services/winget/winget-version.tester.js | 73 ++++++++++++++++++++++++ 1 file changed, 73 insertions(+) create mode 100644 services/winget/winget-version.tester.js 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' }) From d9963b5f5af7c66f11bbdfff1d44a2ae444fce22 Mon Sep 17 00:00:00 2001 From: anatawa12 Date: Fri, 5 Jul 2024 11:09:38 +0900 Subject: [PATCH 4/6] fix: remove debug code --- services/winget/winget-version.service.js | 27 +++++++++-------------- 1 file changed, 11 insertions(+), 16 deletions(-) diff --git a/services/winget/winget-version.service.js b/services/winget/winget-version.service.js index add8962d8286e..6d9cea6023e89 100644 --- a/services/winget/winget-version.service.js +++ b/services/winget/winget-version.service.js @@ -76,22 +76,17 @@ export default class WingetVersion extends GithubAuthV4Service { } async handle({ name }) { - try { - 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 }) - } catch (e) { - console.log(e) - throw e + 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 }) } } From 6b707327b7bb6d0582d2c44ac32f1a6616ad5323 Mon Sep 17 00:00:00 2001 From: anatawa12 Date: Fri, 5 Jul 2024 11:11:12 +0900 Subject: [PATCH 5/6] chore: use winget-specific version compare algorithm --- services/winget/version.js | 144 ++++++++++++++++++++++ services/winget/version.spec.js | 28 +++++ services/winget/winget-version.service.js | 3 +- 3 files changed, 174 insertions(+), 1 deletion(-) create mode 100644 services/winget/version.js create mode 100644 services/winget/version.spec.js diff --git a/services/winget/version.js b/services/winget/version.js new file mode 100644 index 0000000000000..ab9f02bfec7f3 --- /dev/null +++ b/services/winget/version.js @@ -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 } diff --git a/services/winget/version.spec.js b/services/winget/version.spec.js new file mode 100644 index 0000000000000..edfb98aa62a10 --- /dev/null +++ b/services/winget/version.spec.js @@ -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) + }) +}) diff --git a/services/winget/winget-version.service.js b/services/winget/winget-version.service.js index 6d9cea6023e89..fc98324d395c6 100644 --- a/services/winget/winget-version.service.js +++ b/services/winget/winget-version.service.js @@ -1,9 +1,10 @@ import Joi from 'joi' import gql from 'graphql-tag' -import { latest, renderVersionBadge } from '../version.js' +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({ From 439c5ef5e83677dd3c26449a4aa37ef3b947db29 Mon Sep 17 00:00:00 2001 From: anatawa12 Date: Wed, 10 Jul 2024 13:57:01 +0900 Subject: [PATCH 6/6] fix: support latest and unknown --- services/winget/version.js | 24 ++++++++++++++++++++++++ services/winget/version.spec.js | 15 +++++++++++++++ 2 files changed, 39 insertions(+) diff --git a/services/winget/version.js b/services/winget/version.js index ab9f02bfec7f3..043b1dc8f9283 100644 --- a/services/winget/version.js +++ b/services/winget/version.js @@ -17,9 +17,33 @@ */ 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('.') diff --git a/services/winget/version.spec.js b/services/winget/version.spec.js index edfb98aa62a10..6d213dc64b690 100644 --- a/services/winget/version.spec.js +++ b/services/winget/version.spec.js @@ -3,6 +3,8 @@ 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) @@ -24,5 +26,18 @@ describe('Winget Version helpers', function () { 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) }) })