diff --git a/services/github/github-release-branch.service.js b/services/github/github-release-branch.service.js new file mode 100644 index 0000000000000..c126e689f1b36 --- /dev/null +++ b/services/github/github-release-branch.service.js @@ -0,0 +1,124 @@ +import Joi from 'joi' +import { NotFound, pathParam, queryParam } from '../index.js' +import { renderVersionBadge } from '../version.js' +import { GithubAuthV3Service } from './github-auth-service.js' +import { documentation, httpErrorsFor } from './github-helpers.js' + +const releaseSchema = Joi.object({ + tag_name: Joi.string().required(), + target_commitish: Joi.string().required(), + prerelease: Joi.boolean().required(), + name: Joi.string().allow(null).allow(''), +}).required() + +const releaseArraySchema = Joi.alternatives().try( + Joi.array().items(releaseSchema), + Joi.array().length(0), +) + +const queryParamSchema = Joi.object({ + include_prereleases: Joi.equal(''), +}).required() + +const branchDescription = ` +Returns the latest release associated with the specified branch. +Releases are matched using the \`target_commitish\` field from the GitHub API. +` + +export default class GithubReleaseBranch extends GithubAuthV3Service { + static category = 'version' + + static route = { + base: 'github/v/release', + pattern: ':user/:repo/:branch', + queryParamSchema, + } + + static openApi = { + '/github/v/release/{user}/{repo}/{branch}': { + get: { + summary: 'GitHub Release (by branch)', + description: `${documentation}\n${branchDescription}`, + parameters: [ + pathParam({ name: 'user', example: 'laravel' }), + pathParam({ name: 'repo', example: 'framework' }), + pathParam({ name: 'branch', example: '13.x' }), + queryParam({ + name: 'include_prereleases', + example: null, + schema: { type: 'boolean' }, + }), + ], + }, + }, + } + + static defaultBadgeData = { label: 'latest-release' } + + findReleaseOnPage({ releases, branch, includePrereleases }) { + for (const release of releases) { + if (release.target_commitish === branch) { + if (!includePrereleases && release.prerelease) { + continue + } + return release + } + } + return undefined + } + + static render({ version, isPrerelease, branch }) { + return renderVersionBadge({ + version, + isPrerelease, + defaultLabel: GithubReleaseBranch.defaultBadgeData.label, + tag: branch, + }) + } + transform({ release, branch }) { + const { tag_name: version, prerelease: isPrerelease } = release + return { version, isPrerelease, branch } + } + + async fetchPage({ user, repo, page }) { + return this._requestJson({ + url: `/repos/${user}/${repo}/releases`, + schema: releaseArraySchema, + httpErrors: httpErrorsFor('repo not found'), + options: { searchParams: { per_page: 100, page } }, + }) + } + + async fetchLatestReleaseByBranch({ user, repo, branch, includePrereleases }) { + let page = 1 + while (true) { + const releases = await this.fetchPage({ user, repo, page }) + if (releases.length === 0) { + const prettyMessage = + page === 1 ? 'no releases found' : 'no release found for branch' + throw new NotFound({ prettyMessage }) + } + const release = this.findReleaseOnPage({ + releases, + branch, + includePrereleases, + }) + if (release) { + return release + } + page++ + } + } + + async handle({ user, repo, branch }, queryParams) { + const includePrereleases = queryParams.include_prereleases !== undefined + const release = await this.fetchLatestReleaseByBranch({ + user, + repo, + branch, + includePrereleases, + }) + const result = this.transform({ release, branch }) + return this.constructor.render(result) + } +} diff --git a/services/github/github-release-branch.service.spec.js b/services/github/github-release-branch.service.spec.js new file mode 100644 index 0000000000000..35d1172e21f3e --- /dev/null +++ b/services/github/github-release-branch.service.spec.js @@ -0,0 +1,86 @@ +import { test, given } from 'sazerac' +import GithubReleaseBranch from './github-release-branch.service.js' + +describe('GithubReleaseBranch service', function () { + test(GithubReleaseBranch.prototype.findReleaseOnPage, () => { + const releases = [ + { tag_name: 'v2.0.0', target_commitish: 'main', prerelease: false }, + { tag_name: 'v1.0.0', target_commitish: '1.x', prerelease: false }, + ] + given({ releases, branch: '1.x', includePrereleases: false }).expect( + releases[1], + ) + given({ releases, branch: 'main', includePrereleases: false }).expect( + releases[0], + ) + given({ releases, branch: '2.x', includePrereleases: false }).expect( + undefined, + ) + given({ + releases: [ + { tag_name: 'v2.0.0-beta', target_commitish: 'main', prerelease: true }, + ], + branch: 'main', + includePrereleases: false, + }).expect(undefined) + given({ + releases: [ + { tag_name: 'v2.0.0-beta', target_commitish: 'main', prerelease: true }, + ], + branch: 'main', + includePrereleases: true, + }).expect({ + tag_name: 'v2.0.0-beta', + target_commitish: 'main', + prerelease: true, + }) + }) + + test(GithubReleaseBranch.prototype.transform, () => { + given({ + release: { + tag_name: 'v9.11.0', + target_commitish: '9.x', + prerelease: false, + }, + branch: '9.x', + }).expect({ + version: 'v9.11.0', + isPrerelease: false, + branch: '9.x', + }) + given({ + release: { + tag_name: 'v2.0.0-beta', + target_commitish: 'main', + prerelease: true, + }, + branch: 'main', + }).expect({ + version: 'v2.0.0-beta', + isPrerelease: true, + branch: 'main', + }) + }) + + test(GithubReleaseBranch.render, () => { + given({ + version: 'v9.11.0', + isPrerelease: false, + branch: '9.x', + }).expect({ + label: 'latest-release@9.x', + message: 'v9.11.0', + color: 'blue', + }) + given({ + version: 'v2.0.0-beta', + isPrerelease: true, + branch: 'main', + }).expect({ + label: 'latest-release@main', + message: 'v2.0.0-beta', + color: 'orange', + }) + }) +}) diff --git a/services/github/github-release-branch.tester.js b/services/github/github-release-branch.tester.js new file mode 100644 index 0000000000000..0643b8e90948d --- /dev/null +++ b/services/github/github-release-branch.tester.js @@ -0,0 +1,180 @@ +import Joi from 'joi' +import { isSemver } from '../test-validators.js' +import { createServiceTester } from '../tester.js' +export const t = await createServiceTester() + +t.create('Release by branch (valid)') + .get('/laravel/framework/13.x.json') + .expectBadge({ + label: 'latest-release@13.x', + message: isSemver, + color: 'blue', + }) + +t.create('Release by branch (valid, mocked)') + .get('/user/repo/main.json') + .intercept(nock => + nock('https://api.github.com') + .get('/repos/user/repo/releases') + .query({ per_page: 100, page: 1 }) + .reply(200, [ + { + tag_name: 'v2.0.0', + target_commitish: 'main', + prerelease: false, + name: '', + }, + ]), + ) + .expectBadge({ + label: 'latest-release@main', + message: 'v2.0.0', + color: 'blue', + }) + +t.create('Release by branch (prerelease, mocked)') + .get('/user/repo/main.json?include_prereleases') + .intercept(nock => + nock('https://api.github.com') + .get('/repos/user/repo/releases') + .query({ per_page: 100, page: 1 }) + .reply(200, [ + { + tag_name: 'v2.0.0-beta', + target_commitish: 'main', + prerelease: true, + name: '', + }, + ]), + ) + .expectBadge({ + label: 'latest-release@main', + message: 'v2.0.0-beta', + color: 'orange', + }) + +t.create('Release by branch (paginated, mocked)') + .get('/user/repo/1.x.json') + .intercept(nock => + nock('https://api.github.com') + .get('/repos/user/repo/releases') + .query({ per_page: 100, page: 1 }) + .reply(200, [ + { + tag_name: 'v2.0.0', + target_commitish: 'main', + prerelease: false, + name: '', + }, + ]) + .get('/repos/user/repo/releases') + .query({ per_page: 100, page: 2 }) + .reply(200, [ + { + tag_name: 'v1.0.0', + target_commitish: '1.x', + prerelease: false, + name: '', + }, + ]), + ) + .expectBadge({ + label: 'latest-release@1.x', + message: 'v1.0.0', + color: 'blue', + }) + +t.create('Release by branch (no matching branch, mocked)') + .get('/user/repo/2.x.json') + .intercept(nock => + nock('https://api.github.com') + .get('/repos/user/repo/releases') + .query({ per_page: 100, page: 1 }) + .reply(200, [ + { + tag_name: 'v1.0.0', + target_commitish: '1.x', + prerelease: false, + name: '', + }, + ]) + .get('/repos/user/repo/releases') + .query({ per_page: 100, page: 2 }) + .reply(200, []), + ) + .expectBadge({ + label: 'latest-release', + message: 'no release found for branch', + }) + +t.create('Release by branch (repo not found)') + .get('/badges/helmets/1.x.json') + .expectBadge({ label: 'latest-release', message: 'repo not found' }) + +t.create('Release by branch (no releases, mocked)') + .get('/user/repo/main.json') + .intercept(nock => + nock('https://api.github.com') + .get('/repos/user/repo/releases') + .query({ per_page: 100, page: 1 }) + .reply(200, []), + ) + .expectBadge({ label: 'latest-release', message: 'no releases found' }) + +t.create('Release by branch (prerelease excluded, mocked)') + .get('/user/repo/main.json') + .intercept(nock => + nock('https://api.github.com') + .get('/repos/user/repo/releases') + .query({ per_page: 100, page: 1 }) + .reply(200, [ + { + tag_name: 'v2.0.0-beta', + target_commitish: 'main', + prerelease: true, + name: '', + }, + ]) + .get('/repos/user/repo/releases') + .query({ per_page: 100, page: 2 }) + .reply(200, []), + ) + .expectBadge({ + label: 'latest-release', + message: 'no release found for branch', + }) + +t.create('Release by branch (stable after prerelease, mocked)') + .get('/user/repo/main.json') + .intercept(nock => + nock('https://api.github.com') + .get('/repos/user/repo/releases') + .query({ per_page: 100, page: 1 }) + .reply(200, [ + { + tag_name: 'v2.0.0-beta', + target_commitish: 'main', + prerelease: true, + name: '', + }, + { + tag_name: 'v2.0.0', + target_commitish: 'main', + prerelease: false, + name: '', + }, + ]), + ) + .expectBadge({ + label: 'latest-release@main', + message: 'v2.0.0', + color: 'blue', + }) + +t.create('Release by branch (prerelease color)') + .get('/laravel/framework/13.x.json?include_prereleases') + .expectBadge({ + label: 'latest-release@13.x', + message: isSemver, + color: Joi.equal('blue', 'orange').required(), + })