From e9c08512ce64252fc28da34a15e8b8668b352534 Mon Sep 17 00:00:00 2001 From: guoxudong Date: Tue, 5 Jul 2022 06:38:05 +0800 Subject: [PATCH] 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> --- services/gitlab/gitlab-issues.service.js | 286 +++++++++++++++++++++++ services/gitlab/gitlab-issues.tester.js | 147 ++++++++++++ services/test-validators.js | 3 + 3 files changed, 436 insertions(+) create mode 100644 services/gitlab/gitlab-issues.service.js create mode 100644 services/gitlab/gitlab-issues.tester.js diff --git a/services/gitlab/gitlab-issues.service.js b/services/gitlab/gitlab-issues.service.js new file mode 100644 index 0000000000..2e8b6b3e20 --- /dev/null +++ b/services/gitlab/gitlab-issues.service.js @@ -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 = ` +

+ 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/. +

+` + +const labelDocumentation = ` +

+ If you want to use multiple labels then please use commas (,) to separate them, e.g. foo,bar. +

+` + +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 }), + }) + } +} diff --git a/services/gitlab/gitlab-issues.tester.js b/services/gitlab/gitlab-issues.tester.js new file mode 100644 index 0000000000..a06a1d5394 --- /dev/null +++ b/services/gitlab/gitlab-issues.tester.js @@ -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, + }) diff --git a/services/test-validators.js b/services/test-validators.js index 4eece6817f..ad1c131204 100644 --- a/services/test-validators.js +++ b/services/test-validators.js @@ -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,