Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[YOUTRACK] Add YouTrack Issues #10011

Draft
wants to merge 12 commits into
base: master
Choose a base branch
from
2 changes: 2 additions & 0 deletions core/server/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,7 @@ const publicConfigSchema = Joi.object({
sonar: defaultService,
teamcity: defaultService,
weblate: defaultService,
youtrack: defaultService,
trace: Joi.boolean().required(),
}).required(),
cacheHeaders: { defaultCacheLengthSeconds: nonNegativeInteger },
Expand Down Expand Up @@ -205,6 +206,7 @@ const privateConfigSchema = Joi.object({
influx_password: Joi.string(),
weblate_api_key: Joi.string(),
youtube_api_key: Joi.string(),
youtrack_token: Joi.string(),
}).required()
const privateMetricsInfluxConfigSchema = privateConfigSchema.append({
influx_username: Joi.string().required(),
Expand Down
9 changes: 9 additions & 0 deletions doc/server-secrets.md
Original file line number Diff line number Diff line change
Expand Up @@ -364,6 +364,15 @@ and create an API key for the YouTube Data API v3.

[youtube credentials]: https://console.developers.google.com/apis/credentials

### YouTrack

- `YOUTRACK_ORIGINS` (yml: `public.services.youtrack.authorizedOrigins`)
- `YOUTRACK_TOKEN` (yml: `private.youtrack_token`)

A Youtrack [Permanent Access Token][youtrack-pat] is required for accessing private content. If you need a Youtrack token for your self-hosted Shields server then we recommend limiting the scopes to the minimal set necessary for the badges you are using.

[youtrack-pat]: https://www.jetbrains.com/help/youtrack/devportal/Manage-Permanent-Token.html

## Error reporting

- `SENTRY_DSN` (yml: `private.sentry_dsn`)
Expand Down
22 changes: 22 additions & 0 deletions services/youtrack/youtrack-base.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { BaseJsonService } from '../index.js'

export default class YoutrackBase extends BaseJsonService {
static auth = {
passKey: 'youtrack_token',
serviceKey: 'youtrack',
}

async fetch({ url, options, schema, httpErrors }) {
return this._requestJson(
this.authHelper.withBearerAuthHeader({
schema,
url,
options,
httpErrors: { 500: 'invalid query', ...httpErrors },
systemErrors: {
ETIMEOUT: { prettyMessage: 'timeout', cacheSeconds: 10 },
},
}),
)
}
}
48 changes: 48 additions & 0 deletions services/youtrack/youtrack-base.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import Joi from 'joi'
import { expect } from 'chai'
import nock from 'nock'
import { cleanUpNockAfterEach, defaultContext } from '../test-helpers.js'
import YoutrackBase from './youtrack-base.js'

class DummyYoutrackService extends YoutrackBase {
static route = { base: 'fake-base' }

async handle() {
const data = await this.fetch({
schema: Joi.any(),
url: 'https://shields.youtrack.cloud/api/issuesGetter/count?fields=count',
})
return { message: data.message }
}
}

describe('YoutrackBase', function () {
describe('auth', function () {
cleanUpNockAfterEach()

const config = {
public: {
services: {
youtrack: {
authorizedOrigins: ['https://shields.youtrack.cloud'],
},
},
},
private: {
youtrack_token: 'fake-key',
},
}

it('sends the auth information as configured', async function () {
const scope = nock('https://shields.youtrack.cloud')
.get('/api/issuesGetter/count?fields=count')
.matchHeader('Authorization', 'Bearer fake-key')
.reply(200, { message: 'fake message' })
expect(
await DummyYoutrackService.invoke(defaultContext, config, {}),
).to.not.have.property('isError')

scope.done()
})
})
})
7 changes: 7 additions & 0 deletions services/youtrack/youtrack-helper.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
const description = `
Returns the number of issues for the specified project based on the \`query\` parameter defined.

NOTE: The \`youtrack_url\` query param is required.
`

export { description }
99 changes: 99 additions & 0 deletions services/youtrack/youtrack-issues.service.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import Joi from 'joi'
import { InvalidResponse, pathParam, queryParam } from '../index.js'
import { optionalUrl } from '../validators.js'
import { metric } from '../text-formatters.js'
import { description } from './youtrack-helper.js'
import YoutrackBase from './youtrack-base.js'

function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms))
}

const schema = Joi.object({
count: Joi.number().required(),
$type: Joi.equal('IssueCountResponse'),
})

const queryParamSchema = Joi.object({
query: Joi.string(),
youtrack_url: optionalUrl,
}).required()

export default class YoutrackIssues extends YoutrackBase {
static category = 'issue-tracking'

static route = {
base: 'youtrack/issues',
pattern: ':project+',
queryParamSchema,
}

static openApi = {
'/youtrack/issues/{project}': {
get: {
summary: 'Youtrack Issues',
description,
parameters: [
pathParam({
name: 'project',
example: 'DEMO',
}),
queryParam({
name: 'youtrack_url',
example: 'https://shields.youtrack.cloud',
required: true,
}),
queryParam({
name: 'query',
example: 'manage state: Unresolved',
description: 'A valid YouTrack search query.',
}),
],
},
},
}

static defaultBadgeData = { label: 'issues', color: 'informational' }

static render({ count }) {
return {
label: 'issues',
message: metric(count),
color: count > 0 ? 'yellow' : 'brightgreen',
}
}

async fetch({ baseUrl, query }) {
// https://www.jetbrains.com.cn/en-us/help/youtrack/devportal/resource-api-issuesGetter-count.html
return super.fetch({
schema,
options: {
method: 'POST',
json: { query },
},
url: `${baseUrl}/api/issuesGetter/count?fields=count`,
})
}

async handle({ project }, { youtrack_url: baseUrl, query = '' }) {
for (let i = 0; i < 6; i++) {
// 6 trials
const data = await this.fetch({
baseUrl,
query: `project: ${project} ${query}`,
})

if (data.count === -1) {
await sleep(500)
continue
}

return this.constructor.render({ count: data.count })
}

throw new InvalidResponse({
prettyMessage: 'invalid',
cacheSeconds: 10,
})
}
}
38 changes: 38 additions & 0 deletions services/youtrack/youtrack-issues.tester.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { createServiceTester } from '../tester.js'
import { isMetric } from '../test-validators.js'

export const t = await createServiceTester()

t.create('Issues (DEMO) (Cloud)')
.get(
'/DEMO.json?youtrack_url=https://shields.youtrack.cloud&query=manage%20state%3A%20Unresolved',
)
.expectBadge({
label: 'issues',
message: isMetric,
})

t.create('Issues (DEMO) (Empty Query) (Cloud)')
.get('/DEMO.json?youtrack_url=https://shields.youtrack.cloud')
.expectBadge({
label: 'issues',
message: isMetric,
})

t.create('Issues (DEMO) (Invalid State) (Cloud Hosted)')
.get(
'/DEMO.json?youtrack_url=https://shields.youtrack.cloud&query=%23ABCDEFG',
)
.expectBadge({
label: 'issues',
message: 'invalid',
})

t.create('Issues (DOESNOTEXIST) (Invalid Project) (Cloud Hosted)')
.get(
'/DOESNOTEXIST.json?youtrack_url=https://shields.youtrack.cloud&query=state%3A%20Unresolved',
)
.expectBadge({
label: 'issues',
message: 'invalid',
})
Loading