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>
This commit is contained in:
351
services/gitlab/gitlab-merge-requests.service.js
Normal file
351
services/gitlab/gitlab-merge-requests.service.js
Normal file
@@ -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 = `
|
||||
<p>
|
||||
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/.
|
||||
<a href="https://docs.gitlab.com/ee/user/gitlab_com/index.html#pagination-response-headers">GitLab's API </a> only reports up to 10k Merge Requests, so badges for projects that have more than 10k will not have an exact count.
|
||||
</p>
|
||||
`
|
||||
|
||||
const labelDocumentation = `
|
||||
<p>
|
||||
If you want to use multiple labels then please use commas (<code>,</code>) to separate them, e.g. <code>foo,bar</code>.
|
||||
</p>
|
||||
`
|
||||
|
||||
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,
|
||||
})
|
||||
}
|
||||
}
|
||||
92
services/gitlab/gitlab-merge-requests.spec.js
Normal file
92
services/gitlab/gitlab-merge-requests.spec.js
Normal file
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
172
services/gitlab/gitlab-merge-requests.tester.js
Normal file
172
services/gitlab/gitlab-merge-requests.tester.js
Normal file
@@ -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,
|
||||
})
|
||||
Reference in New Issue
Block a user