From e95189c1809bdc26f4bd8c65dbdce5c562003497 Mon Sep 17 00:00:00 2001 From: guoxudong Date: Wed, 17 Aug 2022 09:31:23 +0800 Subject: [PATCH] feat: add [gitlabmergerequests] service (#8166) * fix * fix * add unit test * fixes based on review * fix spec test * fix info * fix mr example Co-authored-by: repo-ranger[bot] <39074581+repo-ranger[bot]@users.noreply.github.com> --- .../gitlab/gitlab-merge-requests.service.js | 351 ++++++++++++++++++ services/gitlab/gitlab-merge-requests.spec.js | 92 +++++ .../gitlab/gitlab-merge-requests.tester.js | 172 +++++++++ 3 files changed, 615 insertions(+) create mode 100644 services/gitlab/gitlab-merge-requests.service.js create mode 100644 services/gitlab/gitlab-merge-requests.spec.js create mode 100644 services/gitlab/gitlab-merge-requests.tester.js diff --git a/services/gitlab/gitlab-merge-requests.service.js b/services/gitlab/gitlab-merge-requests.service.js new file mode 100644 index 0000000000..1cc353a222 --- /dev/null +++ b/services/gitlab/gitlab-merge-requests.service.js @@ -0,0 +1,351 @@ +import Joi from 'joi' +import { optionalUrl, nonNegativeInteger } from '../validators.js' +import { metric } from '../text-formatters.js' +import GitLabBase from './gitlab-base.js' + +// The total number of MR is in the `x-total` field in the headers. +// https://docs.gitlab.com/ee/api/index.html#other-pagination-headers +const schema = Joi.object({ + 'x-total': Joi.number().integer(), + 'x-page': nonNegativeInteger, +}) + +const queryParamSchema = Joi.object({ + labels: Joi.string(), + gitlab_url: optionalUrl, +}).required() + +const documentation = ` +

+ You may use your GitLab Project Id (e.g. 278964) or your Project Path (e.g. gitlab-org/gitlab ). + Note that only internet-accessible GitLab instances are supported, for example https://jihulab.com, https://gitlab.gnome.org, or https://gitlab.com/. + GitLab's API only reports up to 10k Merge Requests, so badges for projects that have more than 10k will not have an exact count. +

+` + +const labelDocumentation = ` +

+ If you want to use multiple labels then please use commas (,) to separate them, e.g. foo,bar. +

+` + +export default class GitlabMergeRequests extends GitLabBase { + static category = 'issue-tracking' + + static route = { + base: 'gitlab/merge-requests', + pattern: ':variant(all|open|closed|locked|merged):raw(-raw)?/:project+', + queryParamSchema, + } + + static examples = [ + { + title: 'GitLab merge requests', + pattern: 'open/:project+', + namedParams: { + project: 'gitlab-org/gitlab', + }, + queryParams: { gitlab_url: 'https://gitlab.com' }, + staticPreview: { + label: 'merge requests', + message: '1.4k open', + color: 'blue', + }, + documentation, + }, + { + title: 'GitLab merge requests', + pattern: 'open-raw/:project+', + namedParams: { + project: 'gitlab-org/gitlab', + }, + queryParams: { gitlab_url: 'https://gitlab.com' }, + staticPreview: { + label: 'open merge requests', + message: '1.4k', + color: 'blue', + }, + documentation, + }, + { + title: 'GitLab merge requests by-label', + pattern: 'open/:project+', + namedParams: { + project: 'gitlab-org/gitlab', + }, + queryParams: { + labels: 'test,type::feature', + gitlab_url: 'https://gitlab.com', + }, + staticPreview: { + label: 'test,failure::new merge requests', + message: '3 open', + color: 'blue', + }, + documentation: documentation + labelDocumentation, + }, + { + title: 'GitLab merge requests by-label', + pattern: 'open-raw/:project+', + namedParams: { + project: 'gitlab-org/gitlab', + }, + queryParams: { + labels: 'gitlab-org/gitlab', + gitlab_url: 'https://gitlab.com', + }, + staticPreview: { + label: 'open test,failure::new merge requests', + message: '3', + color: 'blue', + }, + documentation: documentation + labelDocumentation, + }, + { + title: 'GitLab closed merge requests', + pattern: 'closed/:project+', + namedParams: { + project: 'gitlab-org/gitlab', + }, + queryParams: { gitlab_url: 'https://gitlab.com' }, + staticPreview: { + label: 'merge requests', + message: 'more than 10k closed', + color: 'blue', + }, + documentation, + }, + { + title: 'GitLab closed merge requests', + pattern: 'closed-raw/:project+', + namedParams: { + project: 'gitlab-org/gitlab', + }, + queryParams: { gitlab_url: 'https://gitlab.com' }, + staticPreview: { + label: 'closed merge requests', + message: 'more than 10k', + color: 'blue', + }, + documentation, + }, + { + title: 'GitLab closed merge requests by-label', + pattern: 'closed/:project+', + namedParams: { + project: 'gitlab-org/gitlab', + }, + queryParams: { + labels: 'test,type::feature', + gitlab_url: 'https://gitlab.com', + }, + staticPreview: { + label: 'test,failure::new merge requests', + message: '32 closed', + color: 'blue', + }, + documentation: documentation + labelDocumentation, + }, + { + title: 'GitLab closed merge requests by-label', + pattern: 'closed-raw/:project+', + namedParams: { + project: 'gitlab-org/gitlab', + }, + queryParams: { + labels: 'test,type::feature', + gitlab_url: 'https://gitlab.com', + }, + staticPreview: { + label: 'closed test,failure::new merge requests', + message: '32', + color: 'blue', + }, + documentation: documentation + labelDocumentation, + }, + { + title: 'GitLab all merge requests', + pattern: 'all/:project+', + namedParams: { + project: 'gitlab-org/gitlab', + }, + queryParams: { gitlab_url: 'https://gitlab.com' }, + staticPreview: { + label: 'merge requests', + message: 'more than 10k all', + color: 'blue', + }, + documentation, + }, + { + title: 'GitLab all merge requests', + pattern: 'all-raw/:project+', + namedParams: { + project: 'gitlab-org/gitlab', + }, + queryParams: { gitlab_url: 'https://gitlab.com' }, + staticPreview: { + label: 'all merge requests', + message: 'more than 10k', + color: 'blue', + }, + documentation, + }, + { + title: 'GitLab all merge requests by-label', + pattern: 'all-raw/:project+', + namedParams: { + project: 'gitlab-org/gitlab', + }, + queryParams: { + labels: 'test,failure::new', + gitlab_url: 'https://gitlab.com', + }, + staticPreview: { + label: 'all test,failure::new merge requests', + message: '12', + color: 'blue', + }, + documentation: documentation + labelDocumentation, + }, + { + title: 'GitLab locked merge requests', + pattern: 'locked/:project+', + namedParams: { + project: 'gitlab-org/gitlab', + }, + queryParams: { gitlab_url: 'https://gitlab.com' }, + staticPreview: { + label: 'merge requests', + message: '0 locked', + color: 'blue', + }, + documentation, + }, + { + title: 'GitLab locked merge requests by-label', + pattern: 'closed/:project+', + namedParams: { + project: 'gitlab-org/gitlab', + }, + queryParams: { + labels: 'test,type::feature', + gitlab_url: 'https://gitlab.com', + }, + staticPreview: { + label: 'test,failure::new merge requests', + message: '0 locked', + color: 'blue', + }, + documentation: documentation + labelDocumentation, + }, + { + title: 'GitLab merged merge requests', + pattern: 'merged/:project+', + namedParams: { + project: 'gitlab-org/gitlab', + }, + queryParams: { gitlab_url: 'https://gitlab.com' }, + staticPreview: { + label: 'merge requests', + message: 'more than 10k merged', + color: 'blue', + }, + documentation, + }, + { + title: 'GitLab merged merge requests by-label', + pattern: 'merged/:project+', + namedParams: { + project: 'gitlab-org/gitlab', + }, + queryParams: { + labels: 'test,type::feature', + gitlab_url: 'https://gitlab.com', + }, + staticPreview: { + label: 'test,failure::new merge requests', + message: '68 merged', + color: 'blue', + }, + documentation: documentation + labelDocumentation, + }, + ] + + static defaultBadgeData = { label: 'merge requests' } + + static render({ variant, raw, labels, mergeRequestCount }) { + const state = variant + const isMultiLabel = labels && labels.includes(',') + const labelText = labels ? `${isMultiLabel ? `${labels}` : labels} ` : '' + + let labelPrefix = '' + let messageSuffix = '' + if (raw !== undefined) { + labelPrefix = `${state} ` + } else { + messageSuffix = state + } + const message = `${mergeRequestCount > 10000 ? 'more than ' : ''}${metric( + mergeRequestCount + )}${messageSuffix ? ' ' : ''}${messageSuffix}` + return { + label: `${labelPrefix}${labelText}merge requests`, + message, + color: 'blue', + } + } + + async fetch({ project, baseUrl, variant, labels }) { + // https://docs.gitlab.com/ee/api/merge_requests.html#list-project-merge-requests + const { res } = await this._request( + this.authHelper.withBearerAuthHeader({ + url: `${baseUrl}/api/v4/projects/${encodeURIComponent( + project + )}/merge_requests`, + options: { + searchParams: { + state: variant === 'open' ? 'opened' : variant, + page: '1', + per_page: '1', + labels, + }, + }, + errorMessages: { + 404: 'project not found', + }, + }) + ) + return this.constructor._validate(res.headers, schema) + } + + static transform(data) { + if (data['x-total'] !== undefined) { + return data['x-total'] + } else { + // https://docs.gitlab.com/ee/api/index.html#pagination-response-headers + // For performance reasons, if a query returns more than 10,000 records, GitLab doesn’t return `x-total` header. + // Displayed on the page as "more than 10k". + return 10001 + } + } + + async handle( + { variant, raw, project }, + { gitlab_url: baseUrl = 'https://gitlab.com', labels } + ) { + const data = await this.fetch({ + project, + baseUrl, + variant, + labels, + }) + const mergeRequestCount = this.constructor.transform(data) + return this.constructor.render({ + variant, + raw, + labels, + mergeRequestCount, + }) + } +} diff --git a/services/gitlab/gitlab-merge-requests.spec.js b/services/gitlab/gitlab-merge-requests.spec.js new file mode 100644 index 0000000000..dde1292568 --- /dev/null +++ b/services/gitlab/gitlab-merge-requests.spec.js @@ -0,0 +1,92 @@ +import { test, given } from 'sazerac' +import nock from 'nock' +import { expect } from 'chai' +import { cleanUpNockAfterEach, defaultContext } from '../test-helpers.js' +import GitlabMergeRequests from './gitlab-merge-requests.service.js' + +describe('GitlabMergeRequests', function () { + test(GitlabMergeRequests.render, () => { + given({ variant: 'open', mergeRequestCount: 1399 }).expect({ + label: 'merge requests', + message: '1.4k open', + color: 'blue', + }) + given({ variant: 'open', raw: '-raw', mergeRequestCount: 1399 }).expect({ + label: 'open merge requests', + message: '1.4k', + color: 'blue', + }) + given({ + variant: 'open', + labels: 'discussion,enhancement', + mergeRequestCount: 15, + }).expect({ + label: 'discussion,enhancement merge requests', + message: '15 open', + color: 'blue', + }) + given({ + variant: 'open', + raw: '-raw', + labels: 'discussion,enhancement', + mergeRequestCount: 15, + }).expect({ + label: 'open discussion,enhancement merge requests', + message: '15', + color: 'blue', + }) + given({ variant: 'open', mergeRequestCount: 0 }).expect({ + label: 'merge requests', + message: '0 open', + color: 'blue', + }) + given({ variant: 'open', mergeRequestCount: 10001 }).expect({ + label: 'merge requests', + message: 'more than 10k open', + color: 'blue', + }) + }) + describe('auth', function () { + cleanUpNockAfterEach() + + const fakeToken = 'abc123' + const config = { + public: { + services: { + gitlab: { + authorizedOrigins: ['https://gitlab.com'], + }, + }, + }, + private: { + gitlab_token: fakeToken, + }, + } + + it('sends the auth information as configured', async function () { + const scope = nock('https://gitlab.com/') + .get( + '/api/v4/projects/foo%2Fbar/merge_requests?state=opened&page=1&per_page=1' + ) + // This ensures that the expected credentials are actually being sent with the HTTP request. + // Without this the request wouldn't match and the test would fail. + .matchHeader('Authorization', `Bearer ${fakeToken}`) + .reply(200, {}, { 'x-total': '100', 'x-page': '1' }) + + expect( + await GitlabMergeRequests.invoke( + defaultContext, + config, + { project: 'foo/bar', variant: 'open' }, + {} + ) + ).to.deep.equal({ + label: 'merge requests', + message: '100 open', + color: 'blue', + }) + + scope.done() + }) + }) +}) diff --git a/services/gitlab/gitlab-merge-requests.tester.js b/services/gitlab/gitlab-merge-requests.tester.js new file mode 100644 index 0000000000..0b8c40478f --- /dev/null +++ b/services/gitlab/gitlab-merge-requests.tester.js @@ -0,0 +1,172 @@ +import Joi from 'joi' +import { createServiceTester } from '../tester.js' +import { + isMetric, + isMetricOpenIssues, + isMetricClosedIssues, +} from '../test-validators.js' + +export const t = await createServiceTester() + +t.create('Merge Requests (project not found)') + .get('/open/guoxudong.io/shields-test/do-not-exist.json') + .expectBadge({ + label: 'merge requests', + message: 'project not found', + }) + +/** + * Opened issue number case + */ +t.create('Opened merge requests') + .get('/open/guoxudong.io/shields-test/issue-test.json') + .expectBadge({ + label: 'merge requests', + message: isMetricOpenIssues, + }) + +t.create('Open merge requests raw') + .get('/open-raw/guoxudong.io/shields-test/issue-test.json') + .expectBadge({ + label: 'open merge requests', + message: isMetric, + }) + +t.create('Open merge requests by label is > zero') + .get('/open/guoxudong.io/shields-test/issue-test.json?labels=discussion') + .expectBadge({ + label: 'discussion merge requests', + message: isMetricOpenIssues, + }) + +t.create('Open merge requests by multi-word label is > zero') + .get( + '/open/guoxudong.io/shields-test/issue-test.json?labels=discussion,enhancement' + ) + .expectBadge({ + label: 'discussion,enhancement merge requests', + message: isMetricOpenIssues, + }) + +t.create('Open merge requests by label (raw)') + .get('/open-raw/guoxudong.io/shields-test/issue-test.json?labels=discussion') + .expectBadge({ + label: 'open discussion merge requests', + message: isMetric, + }) + +t.create('Opened merge requests by Scoped labels') + .get('/open/gitlab-org%2Fgitlab.json?labels=test,failure::new') + .expectBadge({ + label: 'test,failure::new merge requests', + message: Joi.alternatives(isMetricOpenIssues, Joi.equal('0 open')), + }) + +/** + * Closed issue number case + */ +t.create('Closed merge requests') + .get('/closed/guoxudong.io/shields-test/issue-test.json') + .expectBadge({ + label: 'merge requests', + message: isMetricClosedIssues, + }) + +t.create('Closed merge requests raw') + .get('/closed-raw/guoxudong.io/shields-test/issue-test.json') + .expectBadge({ + label: 'closed merge requests', + message: isMetric, + }) + +t.create('Closed merge requests by label is > zero') + .get('/closed/guoxudong.io/shields-test/issue-test.json?labels=bug') + .expectBadge({ + label: 'bug merge requests', + message: Joi.alternatives(isMetricClosedIssues, Joi.equal('0 closed')), + }) + +t.create('Closed merge requests by multi-word label is > zero') + .get('/closed/guoxudong.io/shields-test/issue-test.json?labels=bug,critical') + .expectBadge({ + label: 'bug,critical merge requests', + message: Joi.alternatives(isMetricClosedIssues, Joi.equal('0 closed')), + }) + +t.create('Closed merge requests by label (raw)') + .get( + '/closed-raw/guoxudong.io/shields-test/issue-test.json?labels=enhancement' + ) + .expectBadge({ + label: 'closed enhancement merge requests', + message: isMetric, + }) + +/** + * All issue number case + */ +t.create('All merge requests') + .get('/all/guoxudong.io/shields-test/issue-test.json') + .expectBadge({ + label: 'merge requests', + message: Joi.string().regex( + /^([0-9]+[kMGTPEZY]?|[1-9]\.[1-9][kMGTPEZY]) all$/ + ), + }) + +t.create('All merge requests raw') + .get('/all-raw/guoxudong.io/shields-test/issue-test.json') + .expectBadge({ + label: 'all merge requests', + message: isMetric, + }) + +t.create('All merge requests by label is > zero') + .get('/all/guoxudong.io/shields-test/issue-test.json?labels=discussion') + .expectBadge({ + label: 'discussion merge requests', + message: Joi.string().regex( + /^([0-9]+[kMGTPEZY]?|[1-9]\.[1-9][kMGTPEZY]) all$/ + ), + }) + +t.create('All merge requests by multi-word label is > zero') + .get( + '/all/guoxudong.io/shields-test/issue-test.json?labels=discussion,enhancement' + ) + .expectBadge({ + label: 'discussion,enhancement merge requests', + message: Joi.string().regex( + /^([0-9]+[kMGTPEZY]?|[1-9]\.[1-9][kMGTPEZY]) all$/ + ), + }) + +t.create('All merge requests by label (raw)') + .get('/all-raw/guoxudong.io/shields-test/issue-test.json?labels=discussion') + .expectBadge({ + label: 'all discussion merge requests', + message: isMetric, + }) + +t.create('more than 10k merge requests') + .get('/all/gitlab-org%2Fgitlab.json') + .expectBadge({ + label: 'merge requests', + message: 'more than 10k all', + }) + +t.create('locked merge requests') + .get('/locked/gitlab-org%2Fgitlab.json') + .expectBadge({ + label: 'merge requests', + message: Joi.string().regex( + /^([0-9]+[kMGTPEZY]?|[1-9]\.[1-9][kMGTPEZY]) locked$/ + ), + }) + +t.create('Opened merge requests (self-managed)') + .get('/open/gitlab-cn/gitlab.json?gitlab_url=https://jihulab.com') + .expectBadge({ + label: 'merge requests', + message: isMetricOpenIssues, + })