From 8c7872a6663f580116414cc76523097523312943 Mon Sep 17 00:00:00 2001 From: Ambati Mohan Kumar <96166124+MohanKumarAmbati@users.noreply.github.com> Date: Sat, 2 Nov 2024 23:07:07 +0530 Subject: [PATCH 1/4] [npm] - Last update badge added (#10641) * Added npm last update badge * extended NpmBase class instead of BaseJsonService. * added scoped packages to last update. * introduced additionalQueryParamSchema this is to add other query params schema, other than the one present in NpmBase. * removed version query param * in absence of modified date, it'll fetch created. * removed version query param. * added dist-tags. * Update services/npm/npm-last-update.service.js Co-authored-by: jNullj <15849761+jNullj@users.noreply.github.com> * refactored handle method for dist-tags. * Update services/npm/npm-last-update.service.js Co-authored-by: chris48s * added date validation check. * added date validation check. * added date validation check. --------- Co-authored-by: jNullj <15849761+jNullj@users.noreply.github.com> Co-authored-by: chris48s --- services/npm/npm-base.js | 21 +++++ services/npm/npm-last-update.service.js | 106 ++++++++++++++++++++++++ services/npm/npm-last-update.tester.js | 53 ++++++++++++ 3 files changed, 180 insertions(+) create mode 100644 services/npm/npm-last-update.service.js create mode 100644 services/npm/npm-last-update.tester.js diff --git a/services/npm/npm-base.js b/services/npm/npm-base.js index 0e461e8143979..cf1afeb89415c 100644 --- a/services/npm/npm-base.js +++ b/services/npm/npm-base.js @@ -143,4 +143,25 @@ export default class NpmBase extends BaseJsonService { return this.constructor._validate(packageData, packageDataSchema) } + + async fetch({ registryUrl, scope, packageName, schema }) { + registryUrl = registryUrl || this.constructor.defaultRegistryUrl + let url + + if (scope === undefined) { + url = `${registryUrl}/${packageName}` + } else { + const scoped = this.constructor.encodeScopedPackage({ + scope, + packageName, + }) + url = `${registryUrl}/${scoped}` + } + + return this._requestJson({ + url, + schema, + httpErrors: { 404: 'package not found' }, + }) + } } diff --git a/services/npm/npm-last-update.service.js b/services/npm/npm-last-update.service.js new file mode 100644 index 0000000000000..e68a424285030 --- /dev/null +++ b/services/npm/npm-last-update.service.js @@ -0,0 +1,106 @@ +import Joi from 'joi' +import dayjs from 'dayjs' +import { InvalidResponse, NotFound, pathParam, queryParam } from '../index.js' +import { formatDate } from '../text-formatters.js' +import { age as ageColor } from '../color-formatters.js' +import NpmBase, { packageNameDescription } from './npm-base.js' + +const updateResponseSchema = Joi.object({ + time: Joi.object({ + created: Joi.string().required(), + modified: Joi.string().required(), + }) + .pattern(Joi.string().required(), Joi.string().required()) + .required(), + 'dist-tags': Joi.object() + .pattern(Joi.string().required(), Joi.string().required()) + .required(), +}).required() + +export class NpmLastUpdate extends NpmBase { + static category = 'activity' + + static route = this.buildRoute('npm/last-update', { withTag: true }) + + static openApi = { + '/npm/last-update/{packageName}': { + get: { + summary: 'NPM Last Update', + parameters: [ + pathParam({ + name: 'packageName', + example: 'verdaccio', + packageNameDescription, + }), + queryParam({ + name: 'registry_uri', + example: 'https://registry.npmjs.com', + }), + ], + }, + }, + '/npm/last-update/{packageName}/{tag}': { + get: { + summary: 'NPM Last Update (with dist tag)', + parameters: [ + pathParam({ + name: 'packageName', + example: 'verdaccio', + packageNameDescription, + }), + pathParam({ + name: 'tag', + example: 'next-8', + }), + queryParam({ + name: 'registry_uri', + example: 'https://registry.npmjs.com', + }), + ], + }, + }, + } + + static defaultBadgeData = { label: 'last updated' } + + static render({ date }) { + return { + message: formatDate(date), + color: ageColor(date), + } + } + + async handle(namedParams, queryParams) { + const { scope, packageName, tag, registryUrl } = + this.constructor.unpackParams(namedParams, queryParams) + + const packageData = await this.fetch({ + registryUrl, + scope, + packageName, + schema: updateResponseSchema, + }) + + let date + + if (tag) { + const tagVersion = packageData['dist-tags'][tag] + + if (!tagVersion) { + throw new NotFound({ prettyMessage: 'tag not found' }) + } + + date = dayjs(packageData.time[tagVersion]) + } else { + const timeKey = packageData.time.modified ? 'modified' : 'created' + + date = dayjs(packageData.time[timeKey]) + } + + if (!date.isValid) { + throw new InvalidResponse({ prettyMessage: 'invalid date' }) + } + + return this.constructor.render({ date }) + } +} diff --git a/services/npm/npm-last-update.tester.js b/services/npm/npm-last-update.tester.js new file mode 100644 index 0000000000000..8a0efa64528ba --- /dev/null +++ b/services/npm/npm-last-update.tester.js @@ -0,0 +1,53 @@ +import { isFormattedDate } from '../test-validators.js' +import { createServiceTester } from '../tester.js' + +export const t = await createServiceTester() + +t.create('last updated date (valid package)') + .get('/verdaccio.json') + .expectBadge({ + label: 'last updated', + message: isFormattedDate, + }) + +t.create('last updated date (invalid package)') + .get('/not-a-package.json') + .expectBadge({ + label: 'last updated', + message: 'package not found', + }) + +t.create('last update from custom repository (valid scenario)') + .get('/verdaccio.json?registry_uri=https://registry.npmjs.com') + .expectBadge({ + label: 'last updated', + message: isFormattedDate, + }) + +t.create('last update scoped package (valid scenario)') + .get('/@npm/types.json') + .expectBadge({ + label: 'last updated', + message: isFormattedDate, + }) + +t.create('last update scoped package (invalid scenario)') + .get('/@not-a-scoped-package/not-a-valid-package.json') + .expectBadge({ + label: 'last updated', + message: 'package not found', + }) + +t.create('last updated date with tag (valid scenario)') + .get('/verdaccio/latest.json') + .expectBadge({ + label: 'last updated', + message: isFormattedDate, + }) + +t.create('last updated date (invalid tag)') + .get('/verdaccio/not-a-valid-tag.json') + .expectBadge({ + label: 'last updated', + message: 'tag not found', + }) From 57520a974fd05a6d7cb6b4e062eb4a44cd4038d2 Mon Sep 17 00:00:00 2001 From: Pierre-Yves Bigourdan <10694593+PyvesB@users.noreply.github.com> Date: Sat, 2 Nov 2024 19:34:25 +0100 Subject: [PATCH 2/4] Remove [Nuget MyGet] color tests (#10654) --- services/myget/myget.tester.js | 126 -------------------------------- services/nuget-fixtures.js | 57 --------------- services/nuget/nuget.tester.js | 129 ++++++++------------------------- 3 files changed, 31 insertions(+), 281 deletions(-) delete mode 100644 services/nuget-fixtures.js diff --git a/services/myget/myget.tester.js b/services/myget/myget.tester.js index 48dc842207429..39a7bea2e34b4 100644 --- a/services/myget/myget.tester.js +++ b/services/myget/myget.tester.js @@ -3,12 +3,6 @@ import { isMetric, isVPlusDottedVersionNClausesWithOptionalSuffix, } from '../test-validators.js' -import { - queryIndex, - nuGetV3VersionJsonWithDash, - nuGetV3VersionJsonFirstCharZero, - nuGetV3VersionJsonFirstCharNotZero, -} from '../nuget-fixtures.js' import { invalidJSON } from '../response-fixtures.js' export const t = new ServiceTester({ @@ -75,66 +69,6 @@ t.create('version (tenant)') message: isVPlusDottedVersionNClausesWithOptionalSuffix, }) -t.create('version (yellow badge)') - .get('/myget/mongodb/v/MongoDB.Driver.Core.json') - .intercept(nock => - nock('https://www.myget.org') - .get('/F/mongodb/api/v3/index.json') - .reply(200, queryIndex), - ) - .intercept(nock => - nock('https://api-v2v3search-0.nuget.org') - .get( - '/query?q=packageid%3Amongodb.driver.core&prerelease=true&semVerLevel=2', - ) - .reply(200, nuGetV3VersionJsonWithDash), - ) - .expectBadge({ - label: 'mongodb', - message: 'v1.2-beta', - color: 'yellow', - }) - -t.create('version (orange badge)') - .get('/myget/mongodb/v/MongoDB.Driver.Core.json') - .intercept(nock => - nock('https://www.myget.org') - .get('/F/mongodb/api/v3/index.json') - .reply(200, queryIndex), - ) - .intercept(nock => - nock('https://api-v2v3search-0.nuget.org') - .get( - '/query?q=packageid%3Amongodb.driver.core&prerelease=true&semVerLevel=2', - ) - .reply(200, nuGetV3VersionJsonFirstCharZero), - ) - .expectBadge({ - label: 'mongodb', - message: 'v0.35', - color: 'orange', - }) - -t.create('version (blue badge)') - .get('/myget/mongodb/v/MongoDB.Driver.Core.json') - .intercept(nock => - nock('https://www.myget.org') - .get('/F/mongodb/api/v3/index.json') - .reply(200, queryIndex), - ) - .intercept(nock => - nock('https://api-v2v3search-0.nuget.org') - .get( - '/query?q=packageid%3Amongodb.driver.core&prerelease=true&semVerLevel=2', - ) - .reply(200, nuGetV3VersionJsonFirstCharNotZero), - ) - .expectBadge({ - label: 'mongodb', - message: 'v1.2.7', - color: 'blue', - }) - t.create('version (not found)') .get('/myget/foo/v/not-a-real-package.json') .expectBadge({ label: 'myget', message: 'package not found' }) @@ -148,66 +82,6 @@ t.create('version (pre) (valid)') message: isVPlusDottedVersionNClausesWithOptionalSuffix, }) -t.create('version (pre) (yellow badge)') - .get('/myget/mongodb/vpre/MongoDB.Driver.Core.json') - .intercept(nock => - nock('https://www.myget.org') - .get('/F/mongodb/api/v3/index.json') - .reply(200, queryIndex), - ) - .intercept(nock => - nock('https://api-v2v3search-0.nuget.org') - .get( - '/query?q=packageid%3Amongodb.driver.core&prerelease=true&semVerLevel=2', - ) - .reply(200, nuGetV3VersionJsonWithDash), - ) - .expectBadge({ - label: 'mongodb', - message: 'v1.2-beta', - color: 'yellow', - }) - -t.create('version (pre) (orange badge)') - .get('/myget/mongodb/vpre/MongoDB.Driver.Core.json') - .intercept(nock => - nock('https://www.myget.org') - .get('/F/mongodb/api/v3/index.json') - .reply(200, queryIndex), - ) - .intercept(nock => - nock('https://api-v2v3search-0.nuget.org') - .get( - '/query?q=packageid%3Amongodb.driver.core&prerelease=true&semVerLevel=2', - ) - .reply(200, nuGetV3VersionJsonFirstCharZero), - ) - .expectBadge({ - label: 'mongodb', - message: 'v0.35', - color: 'orange', - }) - -t.create('version (pre) (blue badge)') - .get('/myget/mongodb/vpre/MongoDB.Driver.Core.json') - .intercept(nock => - nock('https://www.myget.org') - .get('/F/mongodb/api/v3/index.json') - .reply(200, queryIndex), - ) - .intercept(nock => - nock('https://api-v2v3search-0.nuget.org') - .get( - '/query?q=packageid%3Amongodb.driver.core&prerelease=true&semVerLevel=2', - ) - .reply(200, nuGetV3VersionJsonFirstCharNotZero), - ) - .expectBadge({ - label: 'mongodb', - message: 'v1.2.7', - color: 'blue', - }) - t.create('version (pre) (not found)') .get('/myget/foo/vpre/not-a-real-package.json') .expectBadge({ label: 'myget', message: 'package not found' }) diff --git a/services/nuget-fixtures.js b/services/nuget-fixtures.js deleted file mode 100644 index 919cdd3b0bcf4..0000000000000 --- a/services/nuget-fixtures.js +++ /dev/null @@ -1,57 +0,0 @@ -const queryIndex = JSON.stringify({ - resources: [ - { - '@id': 'https://api-v2v3search-0.nuget.org/query', - '@type': 'SearchQueryService', - }, - ], -}) - -const nuGetV3VersionJsonWithDash = JSON.stringify({ - data: [ - { - totalDownloads: 0, - versions: [{ version: '1.2-beta' }], - }, - ], -}) -const nuGetV3VersionJsonFirstCharZero = JSON.stringify({ - data: [ - { - totalDownloads: 0, - versions: [{ version: '0.35' }], - }, - ], -}) -const nuGetV3VersionJsonFirstCharNotZero = JSON.stringify({ - data: [ - { - totalDownloads: 0, - versions: [{ version: '1.2.7' }], - }, - ], -}) - -const nuGetV3VersionJsonBuildMetadataWithDash = JSON.stringify({ - data: [ - { - totalDownloads: 0, - versions: [ - { - version: '1.16.0+388', - }, - { - version: '1.17.0+1b81349-429', - }, - ], - }, - ], -}) - -export { - queryIndex, - nuGetV3VersionJsonWithDash, - nuGetV3VersionJsonFirstCharZero, - nuGetV3VersionJsonFirstCharNotZero, - nuGetV3VersionJsonBuildMetadataWithDash, -} diff --git a/services/nuget/nuget.tester.js b/services/nuget/nuget.tester.js index 507b4e954e4eb..6d98c65c58588 100644 --- a/services/nuget/nuget.tester.js +++ b/services/nuget/nuget.tester.js @@ -4,16 +4,35 @@ import { isVPlusDottedVersionNClauses, isVPlusDottedVersionNClausesWithOptionalSuffix, } from '../test-validators.js' -import { - queryIndex, - nuGetV3VersionJsonFirstCharZero, - nuGetV3VersionJsonFirstCharNotZero, - nuGetV3VersionJsonBuildMetadataWithDash, -} from '../nuget-fixtures.js' import { invalidJSON } from '../response-fixtures.js' export const t = new ServiceTester({ id: 'nuget', title: 'NuGet' }) +const queryIndex = JSON.stringify({ + resources: [ + { + '@id': 'https://api-v2v3search-0.nuget.org/query', + '@type': 'SearchQueryService', + }, + ], +}) + +const nuGetV3VersionJsonBuildMetadataWithDash = JSON.stringify({ + data: [ + { + totalDownloads: 0, + versions: [ + { + version: '1.16.0+388', + }, + { + version: '1.17.0+1b81349-429', + }, + ], + }, + ], +}) + // downloads t.create('total downloads (valid)') @@ -50,25 +69,11 @@ t.create('version (valid)') message: isVPlusDottedVersionNClauses, }) -t.create('version (orange badge)') - .get('/v/Microsoft.AspNetCore.Mvc.json') - .intercept(nock => - nock('https://api.nuget.org').get('/v3/index.json').reply(200, queryIndex), - ) - .intercept(nock => - nock('https://api-v2v3search-0.nuget.org') - .get( - '/query?q=packageid%3Amicrosoft.aspnetcore.mvc&prerelease=true&semVerLevel=2', - ) - .reply(200, nuGetV3VersionJsonFirstCharZero), - ) - .expectBadge({ - label: 'nuget', - message: 'v0.35', - color: 'orange', - }) +t.create('version (not found)') + .get('/v/not-a-real-package.json') + .expectBadge({ label: 'nuget', message: 'package not found' }) -t.create('version (blue badge)') +t.create('version (unexpected second response)') .get('/v/Microsoft.AspNetCore.Mvc.json') .intercept(nock => nock('https://api.nuget.org').get('/v3/index.json').reply(200, queryIndex), @@ -78,13 +83,9 @@ t.create('version (blue badge)') .get( '/query?q=packageid%3Amicrosoft.aspnetcore.mvc&prerelease=true&semVerLevel=2', ) - .reply(200, nuGetV3VersionJsonFirstCharNotZero), + .reply(invalidJSON), ) - .expectBadge({ - label: 'nuget', - message: 'v1.2.7', - color: 'blue', - }) + .expectBadge({ label: 'nuget', message: 'unparseable json response' }) // https://github.com/badges/shields/issues/4219 t.create('version (build metadata with -)') @@ -103,24 +104,6 @@ t.create('version (build metadata with -)') color: 'blue', }) -t.create('version (not found)') - .get('/v/not-a-real-package.json') - .expectBadge({ label: 'nuget', message: 'package not found' }) - -t.create('version (unexpected second response)') - .get('/v/Microsoft.AspNetCore.Mvc.json') - .intercept(nock => - nock('https://api.nuget.org').get('/v3/index.json').reply(200, queryIndex), - ) - .intercept(nock => - nock('https://api-v2v3search-0.nuget.org') - .get( - '/query?q=packageid%3Amicrosoft.aspnetcore.mvc&prerelease=true&semVerLevel=2', - ) - .reply(invalidJSON), - ) - .expectBadge({ label: 'nuget', message: 'unparseable json response' }) - // version (pre) t.create('version (pre) (valid)') @@ -130,56 +113,6 @@ t.create('version (pre) (valid)') message: isVPlusDottedVersionNClausesWithOptionalSuffix, }) -t.create('version (pre) (orange badge)') - .get('/vpre/Microsoft.AspNetCore.Mvc.json') - .intercept(nock => - nock('https://api.nuget.org').get('/v3/index.json').reply(200, queryIndex), - ) - .intercept(nock => - nock('https://api-v2v3search-0.nuget.org') - .get( - '/query?q=packageid%3Amicrosoft.aspnetcore.mvc&prerelease=true&semVerLevel=2', - ) - .reply(200, nuGetV3VersionJsonFirstCharZero), - ) - .expectBadge({ - label: 'nuget', - message: 'v0.35', - color: 'orange', - }) - -t.create('version (pre) (blue badge)') - .get('/vpre/Microsoft.AspNetCore.Mvc.json') - .intercept(nock => - nock('https://api.nuget.org').get('/v3/index.json').reply(200, queryIndex), - ) - .intercept(nock => - nock('https://api-v2v3search-0.nuget.org') - .get( - '/query?q=packageid%3Amicrosoft.aspnetcore.mvc&prerelease=true&semVerLevel=2', - ) - .reply(200, nuGetV3VersionJsonFirstCharNotZero), - ) - .expectBadge({ - label: 'nuget', - message: 'v1.2.7', - color: 'blue', - }) - t.create('version (pre) (not found)') .get('/vpre/not-a-real-package.json') .expectBadge({ label: 'nuget', message: 'package not found' }) - -t.create('version (pre) (unexpected second response)') - .get('/vpre/Microsoft.AspNetCore.Mvc.json') - .intercept(nock => - nock('https://api.nuget.org').get('/v3/index.json').reply(200, queryIndex), - ) - .intercept(nock => - nock('https://api-v2v3search-0.nuget.org') - .get( - '/query?q=packageid%3Amicrosoft.aspnetcore.mvc&prerelease=true&semVerLevel=2', - ) - .reply(invalidJSON), - ) - .expectBadge({ label: 'nuget', message: 'unparseable json response' }) From 4ec62fa4452c970bac235b604998bd5eea0a60f3 Mon Sep 17 00:00:00 2001 From: usr3 <50021155+usr3@users.noreply.github.com> Date: Mon, 4 Nov 2024 17:10:03 +0530 Subject: [PATCH 3/4] Fix broken URL for pingpong.one (#10655) Fix broken URL for https://pingpong.one for uptime status monitoring. --- services/pingpong/pingpong-base.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/services/pingpong/pingpong-base.js b/services/pingpong/pingpong-base.js index dc8a595f3e41d..62a517613e696 100644 --- a/services/pingpong/pingpong-base.js +++ b/services/pingpong/pingpong-base.js @@ -4,7 +4,7 @@ export const description = ` [PingPong](https://pingpong.one/) is a status page and monitoring service. To see more details about this badge and obtain your api key, visit -[https://my.pingpong.one/integrations/badge-status/](https://my.pingpong.one/integrations/badge-status/) +[https://my.pingpong.one/integrations/badge-uptime/](https://my.pingpong.one/integrations/badge-uptime/) ` export const baseUrl = 'https://api.pingpong.one/widget/shields' From 00d72da97eb46010d613d1061bcec8dd3b00e2f7 Mon Sep 17 00:00:00 2001 From: anatawa12 Date: Tue, 5 Nov 2024 04:05:32 +0900 Subject: [PATCH 4/4] 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 --- services/winget/version.js | 172 +++++++++++ services/winget/version.spec.js | 57 ++++ services/winget/winget-version.service.js | 120 ++++++++ services/winget/winget-version.tester.js | 343 ++++++++++++++++++++++ 4 files changed, 692 insertions(+) create mode 100644 services/winget/version.js create mode 100644 services/winget/version.spec.js create mode 100644 services/winget/winget-version.service.js create mode 100644 services/winget/winget-version.tester.js diff --git a/services/winget/version.js b/services/winget/version.js new file mode 100644 index 0000000000000..9f7c7763463e5 --- /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 0000000000000..d4449b8767585 --- /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 0000000000000..15565a76d6059 --- /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 0000000000000..0bc5bf6c6bf1c --- /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' })