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 isMetricClosedIssues = isMetricWithPattern(/ closed/)
|
||||
|
||||
const isMetricOverMetric = isMetricWithPattern(
|
||||
/\/([1-9][0-9]*[kMGTPEZY]?|[1-9]\.[1-9][kMGTPEZY])/
|
||||
)
|
||||
@@ -167,6 +169,7 @@ export {
|
||||
isMetricAllowNegative,
|
||||
isMetricWithPattern,
|
||||
isMetricOpenIssues,
|
||||
isMetricClosedIssues,
|
||||
isMetricOverMetric,
|
||||
isMetricOverTimePeriod,
|
||||
isZeroOverTimePeriod,
|
||||
|
||||
Reference in New Issue
Block a user