feat: add [gitlabissues] service (#8108)
* feat: add gitlab issues service * fixes based on review * fixes based on review Co-authored-by: repo-ranger[bot] <39074581+repo-ranger[bot]@users.noreply.github.com>
This commit is contained in:
286
services/gitlab/gitlab-issues.service.js
Normal file
286
services/gitlab/gitlab-issues.service.js
Normal file
@@ -0,0 +1,286 @@
|
|||||||
|
import Joi from 'joi'
|
||||||
|
import { optionalUrl, nonNegativeInteger } from '../validators.js'
|
||||||
|
import { metric } from '../text-formatters.js'
|
||||||
|
import GitLabBase from './gitlab-base.js'
|
||||||
|
|
||||||
|
const schema = Joi.object({
|
||||||
|
statistics: Joi.object({
|
||||||
|
counts: Joi.object({
|
||||||
|
all: nonNegativeInteger,
|
||||||
|
closed: nonNegativeInteger,
|
||||||
|
opened: nonNegativeInteger,
|
||||||
|
}).required(),
|
||||||
|
}).allow(null),
|
||||||
|
}).required()
|
||||||
|
|
||||||
|
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/.
|
||||||
|
</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 GitlabIssues extends GitLabBase {
|
||||||
|
static category = 'issue-tracking'
|
||||||
|
|
||||||
|
static route = {
|
||||||
|
base: 'gitlab/issues',
|
||||||
|
pattern: ':variant(all|open|closed):raw(-raw)?/:project+',
|
||||||
|
queryParamSchema,
|
||||||
|
}
|
||||||
|
|
||||||
|
static examples = [
|
||||||
|
{
|
||||||
|
title: 'GitLab issues',
|
||||||
|
pattern: 'open/:project+',
|
||||||
|
namedParams: {
|
||||||
|
project: 'gitlab-org/gitlab',
|
||||||
|
},
|
||||||
|
queryParams: { gitlab_url: 'https://gitlab.com' },
|
||||||
|
staticPreview: {
|
||||||
|
label: 'issues',
|
||||||
|
message: '44k open',
|
||||||
|
color: 'yellow',
|
||||||
|
},
|
||||||
|
documentation,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'GitLab issues',
|
||||||
|
pattern: 'open-raw/:project+',
|
||||||
|
namedParams: {
|
||||||
|
project: 'gitlab-org/gitlab',
|
||||||
|
},
|
||||||
|
queryParams: { gitlab_url: 'https://gitlab.com' },
|
||||||
|
staticPreview: {
|
||||||
|
label: 'open issues',
|
||||||
|
message: '44k',
|
||||||
|
color: 'yellow',
|
||||||
|
},
|
||||||
|
documentation,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'GitLab issues by-label',
|
||||||
|
pattern: 'open/:project+',
|
||||||
|
namedParams: {
|
||||||
|
project: 'gitlab-org/gitlab',
|
||||||
|
},
|
||||||
|
queryParams: {
|
||||||
|
labels: 'test,failure::new',
|
||||||
|
gitlab_url: 'https://gitlab.com',
|
||||||
|
},
|
||||||
|
staticPreview: {
|
||||||
|
label: 'test,failure::new issues',
|
||||||
|
message: '16 open',
|
||||||
|
color: 'yellow',
|
||||||
|
},
|
||||||
|
documentation: documentation + labelDocumentation,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'GitLab issues by-label',
|
||||||
|
pattern: 'open-raw/:project+',
|
||||||
|
namedParams: {
|
||||||
|
project: 'gitlab-org/gitlab',
|
||||||
|
},
|
||||||
|
queryParams: {
|
||||||
|
labels: 'test,failure::new',
|
||||||
|
gitlab_url: 'https://gitlab.com',
|
||||||
|
},
|
||||||
|
staticPreview: {
|
||||||
|
label: 'open test,failure::new issues',
|
||||||
|
message: '16',
|
||||||
|
color: 'yellow',
|
||||||
|
},
|
||||||
|
documentation: documentation + labelDocumentation,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'GitLab closed issues',
|
||||||
|
pattern: 'closed/:project+',
|
||||||
|
namedParams: {
|
||||||
|
project: 'gitlab-org/gitlab',
|
||||||
|
},
|
||||||
|
queryParams: { gitlab_url: 'https://gitlab.com' },
|
||||||
|
staticPreview: {
|
||||||
|
label: 'issues',
|
||||||
|
message: '72k closed',
|
||||||
|
color: 'yellow',
|
||||||
|
},
|
||||||
|
documentation,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'GitLab closed issues',
|
||||||
|
pattern: 'closed-raw/:project+',
|
||||||
|
namedParams: {
|
||||||
|
project: 'gitlab-org/gitlab',
|
||||||
|
},
|
||||||
|
queryParams: { gitlab_url: 'https://gitlab.com' },
|
||||||
|
staticPreview: {
|
||||||
|
label: 'closed issues',
|
||||||
|
message: '72k ',
|
||||||
|
color: 'yellow',
|
||||||
|
},
|
||||||
|
documentation,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'GitLab closed issues by-label',
|
||||||
|
pattern: 'closed/:project+',
|
||||||
|
namedParams: {
|
||||||
|
project: 'gitlab-org/gitlab',
|
||||||
|
},
|
||||||
|
queryParams: {
|
||||||
|
labels: 'test,failure::new',
|
||||||
|
gitlab_url: 'https://gitlab.com',
|
||||||
|
},
|
||||||
|
staticPreview: {
|
||||||
|
label: 'test,failure::new issues',
|
||||||
|
message: '4 closed',
|
||||||
|
color: 'yellow',
|
||||||
|
},
|
||||||
|
documentation: documentation + labelDocumentation,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'GitLab closed issues by-label',
|
||||||
|
pattern: 'closed-raw/:project+',
|
||||||
|
namedParams: {
|
||||||
|
project: 'gitlab-org/gitlab',
|
||||||
|
},
|
||||||
|
queryParams: {
|
||||||
|
labels: 'test,failure::new',
|
||||||
|
gitlab_url: 'https://gitlab.com',
|
||||||
|
},
|
||||||
|
staticPreview: {
|
||||||
|
label: 'closed test,failure::new issues',
|
||||||
|
message: '4',
|
||||||
|
color: 'yellow',
|
||||||
|
},
|
||||||
|
documentation: documentation + labelDocumentation,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'GitLab all issues',
|
||||||
|
pattern: 'all/:project+',
|
||||||
|
namedParams: {
|
||||||
|
project: 'gitlab-org/gitlab',
|
||||||
|
},
|
||||||
|
queryParams: { gitlab_url: 'https://gitlab.com' },
|
||||||
|
staticPreview: {
|
||||||
|
label: 'issues',
|
||||||
|
message: '115k all',
|
||||||
|
color: 'yellow',
|
||||||
|
},
|
||||||
|
documentation,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'GitLab all issues',
|
||||||
|
pattern: 'all-raw/:project+',
|
||||||
|
namedParams: {
|
||||||
|
project: 'gitlab-org/gitlab',
|
||||||
|
},
|
||||||
|
queryParams: { gitlab_url: 'https://gitlab.com' },
|
||||||
|
staticPreview: {
|
||||||
|
label: 'all issues',
|
||||||
|
message: '115k',
|
||||||
|
color: 'yellow',
|
||||||
|
},
|
||||||
|
documentation,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'GitLab all issues 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 issues',
|
||||||
|
message: '20',
|
||||||
|
color: 'yellow',
|
||||||
|
},
|
||||||
|
documentation: documentation + labelDocumentation,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
static defaultBadgeData = { label: 'issues', color: 'informational' }
|
||||||
|
|
||||||
|
static render({ variant, raw, labels, issueCount }) {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
label: `${labelPrefix}${labelText}issues`,
|
||||||
|
message: `${metric(issueCount)}${
|
||||||
|
messageSuffix ? ' ' : ''
|
||||||
|
}${messageSuffix}`,
|
||||||
|
color: issueCount > 0 ? 'yellow' : 'brightgreen',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fetch({ project, baseUrl, labels }) {
|
||||||
|
// https://docs.gitlab.com/ee/api/issues_statistics.html#get-project-issues-statistics
|
||||||
|
return super.fetch({
|
||||||
|
schema,
|
||||||
|
url: `${baseUrl}/api/v4/projects/${encodeURIComponent(
|
||||||
|
project
|
||||||
|
)}/issues_statistics`,
|
||||||
|
options: labels ? { searchParams: { labels } } : undefined,
|
||||||
|
errorMessages: {
|
||||||
|
404: 'project not found',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
static transform({ variant, statistics }) {
|
||||||
|
const state = variant
|
||||||
|
let issueCount
|
||||||
|
switch (state) {
|
||||||
|
case 'open':
|
||||||
|
issueCount = statistics.counts.opened
|
||||||
|
break
|
||||||
|
case 'closed':
|
||||||
|
issueCount = statistics.counts.closed
|
||||||
|
break
|
||||||
|
case 'all':
|
||||||
|
issueCount = statistics.counts.all
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
return issueCount
|
||||||
|
}
|
||||||
|
|
||||||
|
async handle(
|
||||||
|
{ variant, raw, project },
|
||||||
|
{ gitlab_url: baseUrl = 'https://gitlab.com', labels }
|
||||||
|
) {
|
||||||
|
const { statistics } = await this.fetch({
|
||||||
|
project,
|
||||||
|
baseUrl,
|
||||||
|
labels,
|
||||||
|
})
|
||||||
|
return this.constructor.render({
|
||||||
|
variant,
|
||||||
|
raw,
|
||||||
|
labels,
|
||||||
|
issueCount: this.constructor.transform({ variant, statistics }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
147
services/gitlab/gitlab-issues.tester.js
Normal file
147
services/gitlab/gitlab-issues.tester.js
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
import Joi from 'joi'
|
||||||
|
import { createServiceTester } from '../tester.js'
|
||||||
|
import {
|
||||||
|
isMetric,
|
||||||
|
isMetricOpenIssues,
|
||||||
|
isMetricClosedIssues,
|
||||||
|
} from '../test-validators.js'
|
||||||
|
|
||||||
|
export const t = await createServiceTester()
|
||||||
|
|
||||||
|
t.create('Issues (project not found)')
|
||||||
|
.get('/open/guoxudong.io/shields-test/do-not-exist.json')
|
||||||
|
.expectBadge({
|
||||||
|
label: 'issues',
|
||||||
|
message: 'project not found',
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opened issue number case
|
||||||
|
*/
|
||||||
|
t.create('Opened issues')
|
||||||
|
.get('/open/guoxudong.io/shields-test/issue-test.json')
|
||||||
|
.expectBadge({
|
||||||
|
label: 'issues',
|
||||||
|
message: isMetricOpenIssues,
|
||||||
|
})
|
||||||
|
|
||||||
|
t.create('Open issues raw')
|
||||||
|
.get('/open-raw/guoxudong.io/shields-test/issue-test.json')
|
||||||
|
.expectBadge({
|
||||||
|
label: 'open issues',
|
||||||
|
message: isMetric,
|
||||||
|
})
|
||||||
|
|
||||||
|
t.create('Open issues by label is > zero')
|
||||||
|
.get('/open/guoxudong.io/shields-test/issue-test.json?labels=discussion')
|
||||||
|
.expectBadge({
|
||||||
|
label: 'discussion issues',
|
||||||
|
message: isMetricOpenIssues,
|
||||||
|
})
|
||||||
|
|
||||||
|
t.create('Open issues by multi-word label is > zero')
|
||||||
|
.get(
|
||||||
|
'/open/guoxudong.io/shields-test/issue-test.json?labels=discussion,enhancement'
|
||||||
|
)
|
||||||
|
.expectBadge({
|
||||||
|
label: 'discussion,enhancement issues',
|
||||||
|
message: isMetricOpenIssues,
|
||||||
|
})
|
||||||
|
|
||||||
|
t.create('Open issues by label (raw)')
|
||||||
|
.get('/open-raw/guoxudong.io/shields-test/issue-test.json?labels=discussion')
|
||||||
|
.expectBadge({
|
||||||
|
label: 'open discussion issues',
|
||||||
|
message: isMetric,
|
||||||
|
})
|
||||||
|
|
||||||
|
t.create('Opened issues by Scoped labels')
|
||||||
|
.get('/open/gitlab-org%2Fgitlab.json?labels=test,failure::new')
|
||||||
|
.expectBadge({
|
||||||
|
label: 'test,failure::new issues',
|
||||||
|
message: isMetricOpenIssues,
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Closed issue number case
|
||||||
|
*/
|
||||||
|
t.create('Closed issues')
|
||||||
|
.get('/closed/guoxudong.io/shields-test/issue-test.json')
|
||||||
|
.expectBadge({
|
||||||
|
label: 'issues',
|
||||||
|
message: isMetricClosedIssues,
|
||||||
|
})
|
||||||
|
|
||||||
|
t.create('Closed issues raw')
|
||||||
|
.get('/closed-raw/guoxudong.io/shields-test/issue-test.json')
|
||||||
|
.expectBadge({
|
||||||
|
label: 'closed issues',
|
||||||
|
message: isMetric,
|
||||||
|
})
|
||||||
|
|
||||||
|
t.create('Closed issues by label is > zero')
|
||||||
|
.get('/closed/guoxudong.io/shields-test/issue-test.json?labels=bug')
|
||||||
|
.expectBadge({
|
||||||
|
label: 'bug issues',
|
||||||
|
message: isMetricClosedIssues,
|
||||||
|
})
|
||||||
|
|
||||||
|
t.create('Closed issues by multi-word label is > zero')
|
||||||
|
.get('/closed/guoxudong.io/shields-test/issue-test.json?labels=bug,critical')
|
||||||
|
.expectBadge({
|
||||||
|
label: 'bug,critical issues',
|
||||||
|
message: isMetricClosedIssues,
|
||||||
|
})
|
||||||
|
|
||||||
|
t.create('Closed issues by label (raw)')
|
||||||
|
.get('/closed-raw/guoxudong.io/shields-test/issue-test.json?labels=bug')
|
||||||
|
.expectBadge({
|
||||||
|
label: 'closed bug issues',
|
||||||
|
message: isMetric,
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* All issue number case
|
||||||
|
*/
|
||||||
|
t.create('All issues')
|
||||||
|
.get('/all/guoxudong.io/shields-test/issue-test.json')
|
||||||
|
.expectBadge({
|
||||||
|
label: 'issues',
|
||||||
|
message: Joi.string().regex(
|
||||||
|
/^([0-9]+[kMGTPEZY]?|[1-9]\.[1-9][kMGTPEZY]) all$/
|
||||||
|
),
|
||||||
|
})
|
||||||
|
|
||||||
|
t.create('All issues raw')
|
||||||
|
.get('/all-raw/guoxudong.io/shields-test/issue-test.json')
|
||||||
|
.expectBadge({
|
||||||
|
label: 'all issues',
|
||||||
|
message: isMetric,
|
||||||
|
})
|
||||||
|
|
||||||
|
t.create('All issues by label is > zero')
|
||||||
|
.get('/all/guoxudong.io/shields-test/issue-test.json?labels=discussion')
|
||||||
|
.expectBadge({
|
||||||
|
label: 'discussion issues',
|
||||||
|
message: Joi.string().regex(
|
||||||
|
/^([0-9]+[kMGTPEZY]?|[1-9]\.[1-9][kMGTPEZY]) all$/
|
||||||
|
),
|
||||||
|
})
|
||||||
|
|
||||||
|
t.create('All issues by multi-word label is > zero')
|
||||||
|
.get(
|
||||||
|
'/all/guoxudong.io/shields-test/issue-test.json?labels=discussion,enhancement'
|
||||||
|
)
|
||||||
|
.expectBadge({
|
||||||
|
label: 'discussion,enhancement issues',
|
||||||
|
message: Joi.string().regex(
|
||||||
|
/^([0-9]+[kMGTPEZY]?|[1-9]\.[1-9][kMGTPEZY]) all$/
|
||||||
|
),
|
||||||
|
})
|
||||||
|
|
||||||
|
t.create('All issues by label (raw)')
|
||||||
|
.get('/all-raw/guoxudong.io/shields-test/issue-test.json?labels=discussion')
|
||||||
|
.expectBadge({
|
||||||
|
label: 'all discussion issues',
|
||||||
|
message: isMetric,
|
||||||
|
})
|
||||||
@@ -79,6 +79,8 @@ const isMetricWithPattern = nestedRegexp => {
|
|||||||
|
|
||||||
const isMetricOpenIssues = isMetricWithPattern(/ open/)
|
const isMetricOpenIssues = isMetricWithPattern(/ open/)
|
||||||
|
|
||||||
|
const isMetricClosedIssues = isMetricWithPattern(/ closed/)
|
||||||
|
|
||||||
const isMetricOverMetric = isMetricWithPattern(
|
const isMetricOverMetric = isMetricWithPattern(
|
||||||
/\/([1-9][0-9]*[kMGTPEZY]?|[1-9]\.[1-9][kMGTPEZY])/
|
/\/([1-9][0-9]*[kMGTPEZY]?|[1-9]\.[1-9][kMGTPEZY])/
|
||||||
)
|
)
|
||||||
@@ -167,6 +169,7 @@ export {
|
|||||||
isMetricAllowNegative,
|
isMetricAllowNegative,
|
||||||
isMetricWithPattern,
|
isMetricWithPattern,
|
||||||
isMetricOpenIssues,
|
isMetricOpenIssues,
|
||||||
|
isMetricClosedIssues,
|
||||||
isMetricOverMetric,
|
isMetricOverMetric,
|
||||||
isMetricOverTimePeriod,
|
isMetricOverTimePeriod,
|
||||||
isZeroOverTimePeriod,
|
isZeroOverTimePeriod,
|
||||||
|
|||||||
Reference in New Issue
Block a user