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,
+ })