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:
guoxudong
2022-08-17 09:31:23 +08:00
committed by GitHub
parent aa646e01f4
commit e95189c180
3 changed files with 615 additions and 0 deletions

View 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 doesnt 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,
})
}
}

View 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()
})
})
})

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