[Jenkins] coverage badges rewrite (#2154)
This commit is contained in:
@@ -203,16 +203,6 @@ const allBadgeExamples = [
|
||||
previewUrl:
|
||||
'/jenkins/t/https/jenkins.qa.ubuntu.com/view/Precise/view/All%20Precise/job/precise-desktop-amd64_default.svg',
|
||||
},
|
||||
{
|
||||
title: 'Jenkins Coverage (Cobertura)',
|
||||
previewUrl:
|
||||
'/jenkins/c/https/jenkins.qa.ubuntu.com/view/Utopic/view/All/job/address-book-service-utopic-i386-ci.svg',
|
||||
},
|
||||
{
|
||||
title: 'Jenkins Coverage (Jacoco)',
|
||||
previewUrl:
|
||||
'/jenkins/j/https/jenkins.qa.ubuntu.com/view/Utopic/view/All/job/address-book-service-utopic-i386-ci.svg',
|
||||
},
|
||||
{
|
||||
title: 'Bitbucket Pipelines',
|
||||
previewUrl: '/bitbucket/pipelines/atlassian/adf-builder-javascript.svg',
|
||||
|
||||
@@ -1,80 +1,163 @@
|
||||
'use strict'
|
||||
|
||||
const LegacyService = require('../legacy-service')
|
||||
const { makeBadgeData: getBadgeData } = require('../../lib/badge-data')
|
||||
const Joi = require('joi')
|
||||
const BaseJsonService = require('../base-json')
|
||||
const serverSecrets = require('../../lib/server-secrets')
|
||||
const { checkErrorResponse } = require('../../lib/error-helper')
|
||||
|
||||
const {
|
||||
coveragePercentage: coveragePercentageColor,
|
||||
} = require('../../lib/color-formatters')
|
||||
|
||||
// For Jenkins coverage (cobertura + jacoco).
|
||||
module.exports = class JenkinsCoverage extends LegacyService {
|
||||
static registerLegacyRouteHandler({ camp, cache }) {
|
||||
camp.route(
|
||||
/^\/jenkins(?:-ci)?\/(c|j)\/(http(?:s)?)\/([^/]+)\/(.+)\.(svg|png|gif|jpg|json)$/,
|
||||
cache((data, match, sendBadge, request) => {
|
||||
const type = match[1] // c - cobertura | j - jacoco
|
||||
const scheme = match[2] // http(s)
|
||||
const host = match[3] // example.org:8080
|
||||
const job = match[4] // folder/job
|
||||
const format = match[5]
|
||||
const options = {
|
||||
json: true,
|
||||
uri: `${scheme}://${host}/job/${job}/`,
|
||||
}
|
||||
const jacocoCoverageSchema = Joi.object({
|
||||
instructionCoverage: Joi.object({
|
||||
percentage: Joi.number()
|
||||
.min(0)
|
||||
.max(100)
|
||||
.required(),
|
||||
}).required(),
|
||||
}).required()
|
||||
|
||||
if (job.indexOf('/') > -1) {
|
||||
options.uri = `${scheme}://${host}/${job}/`
|
||||
}
|
||||
|
||||
switch (type) {
|
||||
case 'c':
|
||||
options.uri +=
|
||||
'lastBuild/cobertura/api/json?tree=results[elements[name,denominator,numerator,ratio]]'
|
||||
break
|
||||
case 'j':
|
||||
options.uri +=
|
||||
'lastBuild/jacoco/api/json?tree=instructionCoverage[covered,missed,percentage,total]'
|
||||
break
|
||||
}
|
||||
|
||||
if (serverSecrets && serverSecrets.jenkins_user) {
|
||||
options.auth = {
|
||||
user: serverSecrets.jenkins_user,
|
||||
pass: serverSecrets.jenkins_pass,
|
||||
}
|
||||
}
|
||||
|
||||
const badgeData = getBadgeData('coverage', data)
|
||||
request(options, (err, res, json) => {
|
||||
if (checkErrorResponse(badgeData, err, res)) {
|
||||
sendBadge(format, badgeData)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const coverageObject = json.instructionCoverage
|
||||
if (coverageObject === undefined) {
|
||||
badgeData.text[1] = 'inaccessible'
|
||||
sendBadge(format, badgeData)
|
||||
return
|
||||
}
|
||||
const coverage = coverageObject.percentage
|
||||
if (isNaN(coverage)) {
|
||||
badgeData.text[1] = 'unknown'
|
||||
sendBadge(format, badgeData)
|
||||
return
|
||||
}
|
||||
badgeData.text[1] = coverage.toFixed(0) + '%'
|
||||
badgeData.colorscheme = coveragePercentageColor(coverage)
|
||||
sendBadge(format, badgeData)
|
||||
} catch (e) {
|
||||
badgeData.text[1] = 'invalid'
|
||||
sendBadge(format, badgeData)
|
||||
}
|
||||
const coberturaCoverageSchema = Joi.object({
|
||||
results: Joi.object({
|
||||
elements: Joi.array()
|
||||
.items(
|
||||
Joi.object({
|
||||
name: 'Lines',
|
||||
ratio: Joi.number()
|
||||
.min(0)
|
||||
.max(100)
|
||||
.required(),
|
||||
})
|
||||
})
|
||||
)
|
||||
)
|
||||
.min(1)
|
||||
.required(),
|
||||
}).required(),
|
||||
}).required()
|
||||
|
||||
class BaseJenkinsCoverage extends BaseJsonService {
|
||||
async fetch({ url, options, schema }) {
|
||||
return this._requestJson({
|
||||
url,
|
||||
options,
|
||||
schema,
|
||||
errorMessages: {
|
||||
404: 'job or coverage not found',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
static render({ coverage }) {
|
||||
return {
|
||||
message: `${coverage.toFixed(0)}%`,
|
||||
color: coveragePercentageColor(coverage),
|
||||
}
|
||||
}
|
||||
|
||||
static get defaultBadgeData() {
|
||||
return { label: 'coverage' }
|
||||
}
|
||||
|
||||
static get category() {
|
||||
return 'build'
|
||||
}
|
||||
|
||||
static buildUrl(scheme, host, job, plugin) {
|
||||
return `${scheme}://${host}/job/${job}/lastBuild/${plugin}/api/json`
|
||||
}
|
||||
|
||||
static buildOptions(treeParam) {
|
||||
const options = {
|
||||
qs: {
|
||||
tree: treeParam,
|
||||
},
|
||||
}
|
||||
if (serverSecrets && serverSecrets.jenkins_user) {
|
||||
options.auth = {
|
||||
user: serverSecrets.jenkins_user,
|
||||
pass: serverSecrets.jenkins_pass,
|
||||
}
|
||||
}
|
||||
return options
|
||||
}
|
||||
}
|
||||
|
||||
class JacocoJenkinsCoverage extends BaseJenkinsCoverage {
|
||||
async handle({ scheme, host, job }) {
|
||||
const url = this.constructor.buildUrl(scheme, host, job, 'jacoco')
|
||||
const options = this.constructor.buildOptions(
|
||||
'instructionCoverage[percentage]'
|
||||
)
|
||||
const json = await this.fetch({
|
||||
url,
|
||||
options,
|
||||
schema: jacocoCoverageSchema,
|
||||
})
|
||||
return this.constructor.render({
|
||||
coverage: json.instructionCoverage.percentage,
|
||||
})
|
||||
}
|
||||
|
||||
static get url() {
|
||||
return {
|
||||
base: 'jenkins/j',
|
||||
format: '(http(?:s)?)/([^/]+)/(?:job/)?(.+)',
|
||||
capture: ['scheme', 'host', 'job'],
|
||||
}
|
||||
}
|
||||
|
||||
static get examples() {
|
||||
return [
|
||||
{
|
||||
title: 'Jenkins JaCoCo coverage',
|
||||
exampleUrl: 'https/ci.eclipse.org/ecp/job/gerrit',
|
||||
urlPattern: ':scheme/:host/:job',
|
||||
staticExample: this.render({
|
||||
coverage: 96,
|
||||
}),
|
||||
},
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
class CoberturaJenkinsCoverage extends BaseJenkinsCoverage {
|
||||
async handle({ scheme, host, job }) {
|
||||
const url = this.constructor.buildUrl(scheme, host, job, 'cobertura')
|
||||
const options = this.constructor.buildOptions(
|
||||
'results[elements[name,ratio]]'
|
||||
)
|
||||
const json = await this.fetch({
|
||||
url,
|
||||
options,
|
||||
schema: coberturaCoverageSchema,
|
||||
})
|
||||
return this.constructor.render({
|
||||
coverage: json.results.elements[0].ratio,
|
||||
})
|
||||
}
|
||||
|
||||
static get url() {
|
||||
return {
|
||||
base: 'jenkins/c',
|
||||
format: '(http(?:s)?)/([^/]+)/(?:job/)?(.+)',
|
||||
capture: ['scheme', 'host', 'job'],
|
||||
}
|
||||
}
|
||||
|
||||
static get examples() {
|
||||
return [
|
||||
{
|
||||
title: 'Jenkins Cobertura coverage',
|
||||
exampleUrl: 'https/builds.apache.org/job/olingo-odata4-cobertura',
|
||||
urlPattern: ':scheme/:host/:job',
|
||||
staticExample: this.render({
|
||||
coverage: 94,
|
||||
}),
|
||||
},
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
JacocoJenkinsCoverage,
|
||||
CoberturaJenkinsCoverage,
|
||||
}
|
||||
|
||||
170
services/jenkins/jenkins-coverage.tester.js
Normal file
170
services/jenkins/jenkins-coverage.tester.js
Normal file
@@ -0,0 +1,170 @@
|
||||
'use strict'
|
||||
|
||||
const ServiceTester = require('../service-tester')
|
||||
|
||||
const t = new ServiceTester({
|
||||
id: 'jenkins-coverage',
|
||||
title: 'JenkinsCoverage',
|
||||
pathPrefix: '/jenkins',
|
||||
})
|
||||
module.exports = t
|
||||
|
||||
t.create('jacoco: valid coverage')
|
||||
.get('/j/https/updates.jenkins-ci.org/job/hello-project/job/master.json')
|
||||
.intercept(nock =>
|
||||
nock('https://updates.jenkins-ci.org')
|
||||
.get(
|
||||
'/job/hello-project/job/master/lastBuild/jacoco/api/json?tree=instructionCoverage%5Bpercentage%5D'
|
||||
)
|
||||
.reply(200, {
|
||||
instructionCoverage: {
|
||||
percentage: 81,
|
||||
},
|
||||
})
|
||||
)
|
||||
.expectJSON({ name: 'coverage', value: '81%' })
|
||||
|
||||
t.create(
|
||||
'jacoco: valid coverage (badge URL without leading /job after Jenkins host)'
|
||||
)
|
||||
.get('/j/https/updates.jenkins-ci.org/hello-project/job/master.json')
|
||||
.intercept(nock =>
|
||||
nock('https://updates.jenkins-ci.org')
|
||||
.get(
|
||||
'/job/hello-project/job/master/lastBuild/jacoco/api/json?tree=instructionCoverage%5Bpercentage%5D'
|
||||
)
|
||||
.reply(200, {
|
||||
instructionCoverage: {
|
||||
percentage: 81,
|
||||
},
|
||||
})
|
||||
)
|
||||
.expectJSON({ name: 'coverage', value: '81%' })
|
||||
|
||||
t.create('jacoco: invalid data response (no instructionCoverage object)')
|
||||
.get('/j/https/updates.jenkins-ci.org/job/hello-project/job/master.json')
|
||||
.intercept(nock =>
|
||||
nock('https://updates.jenkins-ci.org')
|
||||
.get(
|
||||
'/job/hello-project/job/master/lastBuild/jacoco/api/json?tree=instructionCoverage%5Bpercentage%5D'
|
||||
)
|
||||
.reply(200, {
|
||||
invalidCoverageObject: {
|
||||
percentage: 81,
|
||||
},
|
||||
})
|
||||
)
|
||||
.expectJSON({ name: 'coverage', value: 'invalid response data' })
|
||||
|
||||
t.create('jacoco: invalid data response (non numeric coverage)')
|
||||
.get('/j/https/updates.jenkins-ci.org/job/hello-project/job/master.json')
|
||||
.intercept(nock =>
|
||||
nock('https://updates.jenkins-ci.org')
|
||||
.get(
|
||||
'/job/hello-project/job/master/lastBuild/jacoco/api/json?tree=instructionCoverage%5Bpercentage%5D'
|
||||
)
|
||||
.reply(200, {
|
||||
instructionCoverage: {
|
||||
percentage: 'non-numeric',
|
||||
},
|
||||
})
|
||||
)
|
||||
.expectJSON({ name: 'coverage', value: 'invalid response data' })
|
||||
|
||||
t.create('jacoco: job not found')
|
||||
.get('/j/https/updates.jenkins-ci.org/job/does-not-exist.json')
|
||||
.expectJSON({ name: 'coverage', value: 'job or coverage not found' })
|
||||
|
||||
t.create('cobertura: valid coverage')
|
||||
.get('/c/https/updates.jenkins-ci.org/job/hello-project/job/master.json')
|
||||
.intercept(nock =>
|
||||
nock('https://updates.jenkins-ci.org')
|
||||
.get(
|
||||
'/job/hello-project/job/master/lastBuild/cobertura/api/json?tree=results%5Belements%5Bname%2Cratio%5D%5D'
|
||||
)
|
||||
.reply(200, {
|
||||
results: {
|
||||
elements: [
|
||||
{
|
||||
name: 'Conditionals',
|
||||
ratio: 95.146,
|
||||
},
|
||||
{
|
||||
name: 'Lines',
|
||||
ratio: 63.745,
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
)
|
||||
.expectJSON({ name: 'coverage', value: '64%' })
|
||||
|
||||
t.create(
|
||||
'cobertura: valid coverage (badge URL without leading /job after Jenkins host)'
|
||||
)
|
||||
.get('/c/https/updates.jenkins-ci.org/hello-project/job/master.json')
|
||||
.intercept(nock =>
|
||||
nock('https://updates.jenkins-ci.org')
|
||||
.get(
|
||||
'/job/hello-project/job/master/lastBuild/cobertura/api/json?tree=results%5Belements%5Bname%2Cratio%5D%5D'
|
||||
)
|
||||
.reply(200, {
|
||||
results: {
|
||||
elements: [
|
||||
{
|
||||
name: 'Conditionals',
|
||||
ratio: 95.146,
|
||||
},
|
||||
{
|
||||
name: 'Lines',
|
||||
ratio: 63.745,
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
)
|
||||
.expectJSON({ name: 'coverage', value: '64%' })
|
||||
|
||||
t.create('cobertura: invalid data response (non-numeric coverage)')
|
||||
.get('/c/https/updates.jenkins-ci.org/job/hello-project/job/master.json')
|
||||
.intercept(nock =>
|
||||
nock('https://updates.jenkins-ci.org')
|
||||
.get(
|
||||
'/job/hello-project/job/master/lastBuild/cobertura/api/json?tree=results%5Belements%5Bname%2Cratio%5D%5D'
|
||||
)
|
||||
.reply(200, {
|
||||
results: {
|
||||
elements: [
|
||||
{
|
||||
name: 'Lines',
|
||||
ratio: 'non-numeric',
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
)
|
||||
.expectJSON({ name: 'coverage', value: 'invalid response data' })
|
||||
|
||||
t.create('cobertura: invalid data response (missing line coverage)')
|
||||
.get('/c/https/updates.jenkins-ci.org/job/hello-project/job/master.json')
|
||||
.intercept(nock =>
|
||||
nock('https://updates.jenkins-ci.org')
|
||||
.get(
|
||||
'/job/hello-project/job/master/lastBuild/cobertura/api/json?tree=results%5Belements%5Bname%2Cratio%5D%5D'
|
||||
)
|
||||
.reply(200, {
|
||||
results: {
|
||||
elements: [
|
||||
{
|
||||
name: 'Conditionals',
|
||||
ratio: 95.146,
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
)
|
||||
.expectJSON({ name: 'coverage', value: 'invalid response data' })
|
||||
|
||||
t.create('cobertura: job not found')
|
||||
.get('/c/https/updates.jenkins-ci.org/job/does-not-exist.json')
|
||||
.expectJSON({ name: 'coverage', value: 'job or coverage not found' })
|
||||
53
services/jenkins/jenkins-plugin-version.tester.js
Normal file
53
services/jenkins/jenkins-plugin-version.tester.js
Normal file
@@ -0,0 +1,53 @@
|
||||
'use strict'
|
||||
|
||||
const Joi = require('joi')
|
||||
const ServiceTester = require('../service-tester')
|
||||
|
||||
const t = new ServiceTester({
|
||||
id: 'jenkins-plugin',
|
||||
title: 'JenkinsPluginVersion',
|
||||
pathPrefix: '/jenkins',
|
||||
})
|
||||
module.exports = t
|
||||
|
||||
t.create('latest version')
|
||||
.get('/plugin/v/blueocean.json')
|
||||
.intercept(nock =>
|
||||
nock('https://updates.jenkins-ci.org')
|
||||
.get('/current/update-center.actual.json')
|
||||
.reply(200, { plugins: { blueocean: { version: '1.1.6' } } })
|
||||
)
|
||||
.expectJSONTypes(
|
||||
Joi.object().keys({
|
||||
name: 'plugin',
|
||||
value: Joi.string().regex(/^v(.*)$/),
|
||||
})
|
||||
)
|
||||
|
||||
t.create('version 0')
|
||||
.get('/plugin/v/blueocean.json')
|
||||
.intercept(nock =>
|
||||
nock('https://updates.jenkins-ci.org')
|
||||
.get('/current/update-center.actual.json')
|
||||
.reply(200, { plugins: { blueocean: { version: '0' } } })
|
||||
)
|
||||
.expectJSONTypes(
|
||||
Joi.object().keys({
|
||||
name: 'plugin',
|
||||
value: Joi.string().regex(/^v0$/),
|
||||
})
|
||||
)
|
||||
|
||||
t.create('inexistent artifact')
|
||||
.get('/plugin/v/inexistent-artifact-id.json')
|
||||
.intercept(nock =>
|
||||
nock('https://updates.jenkins-ci.org')
|
||||
.get('/current/update-center.actual.json')
|
||||
.reply(200, { plugins: { blueocean: { version: '1.1.6' } } })
|
||||
)
|
||||
.expectJSON({ name: 'plugin', value: 'not found' })
|
||||
|
||||
t.create('connection error')
|
||||
.get('/plugin/v/blueocean.json')
|
||||
.networkOff()
|
||||
.expectJSON({ name: 'plugin', value: 'inaccessible' })
|
||||
@@ -1,130 +0,0 @@
|
||||
'use strict'
|
||||
|
||||
const Joi = require('joi')
|
||||
const ServiceTester = require('../service-tester')
|
||||
|
||||
const t = new ServiceTester({ id: 'jenkins', title: 'Jenkins' })
|
||||
module.exports = t
|
||||
|
||||
t.create('cobertura: latest version')
|
||||
.get('/plugin/v/blueocean.json')
|
||||
.intercept(nock =>
|
||||
nock('https://updates.jenkins-ci.org')
|
||||
.get('/current/update-center.actual.json')
|
||||
.reply(200, { plugins: { blueocean: { version: '1.1.6' } } })
|
||||
)
|
||||
.expectJSONTypes(
|
||||
Joi.object().keys({
|
||||
name: 'plugin',
|
||||
value: Joi.string().regex(/^v(.*)$/),
|
||||
})
|
||||
)
|
||||
|
||||
t.create('cobertura: version 0')
|
||||
.get('/plugin/v/blueocean.json')
|
||||
.intercept(nock =>
|
||||
nock('https://updates.jenkins-ci.org')
|
||||
.get('/current/update-center.actual.json')
|
||||
.reply(200, { plugins: { blueocean: { version: '0' } } })
|
||||
)
|
||||
.expectJSONTypes(
|
||||
Joi.object().keys({
|
||||
name: 'plugin',
|
||||
value: Joi.string().regex(/^v0$/),
|
||||
})
|
||||
)
|
||||
|
||||
t.create('cobertura: inexistent artifact')
|
||||
.get('/plugin/v/inexistent-artifact-id.json')
|
||||
.intercept(nock =>
|
||||
nock('https://updates.jenkins-ci.org')
|
||||
.get('/current/update-center.actual.json')
|
||||
.reply(200, { plugins: { blueocean: { version: '1.1.6' } } })
|
||||
)
|
||||
.expectJSON({ name: 'plugin', value: 'not found' })
|
||||
|
||||
t.create('cobertura: connection error')
|
||||
.get('/plugin/v/blueocean.json')
|
||||
.networkOff()
|
||||
.expectJSON({ name: 'plugin', value: 'inaccessible' })
|
||||
|
||||
t.create('jacoco: 81% | valid coverage')
|
||||
.get('/j/https/updates.jenkins-ci.org/job/hello-project/job/master.json')
|
||||
.intercept(nock =>
|
||||
nock('https://updates.jenkins-ci.org')
|
||||
.get(
|
||||
'/job/hello-project/job/master/lastBuild/jacoco/api/json?tree=instructionCoverage[covered,missed,percentage,total]'
|
||||
)
|
||||
.reply(200, {
|
||||
instructionCoverage: {
|
||||
covered: 39498,
|
||||
missed: 9508,
|
||||
percentage: 81,
|
||||
percentageFloat: 80.5983,
|
||||
total: 49006,
|
||||
},
|
||||
})
|
||||
)
|
||||
.expectJSONTypes({ name: 'coverage', value: '81%' })
|
||||
|
||||
t.create('jacoco: inaccessible | request error')
|
||||
.get('/j/https/updates.jenkins-ci.org/job/hello-project/job/master.json')
|
||||
.networkOff()
|
||||
.expectJSONTypes({ name: 'coverage', value: 'inaccessible' })
|
||||
|
||||
t.create('jacoco: inaccessible | invalid coverage object')
|
||||
.get('/j/https/updates.jenkins-ci.org/job/hello-project/job/master.json')
|
||||
.intercept(nock =>
|
||||
nock('https://updates.jenkins-ci.org')
|
||||
.get(
|
||||
'/job/hello-project/job/master/lastBuild/jacoco/api/json?tree=instructionCoverage[covered,missed,percentage,total]'
|
||||
)
|
||||
.reply(200, {
|
||||
invalidCoverageObject: {
|
||||
covered: 39498,
|
||||
missed: 9508,
|
||||
percentage: 81,
|
||||
percentageFloat: 80.5983,
|
||||
total: 49006,
|
||||
},
|
||||
})
|
||||
)
|
||||
.expectJSONTypes({ name: 'coverage', value: 'inaccessible' })
|
||||
|
||||
t.create('jacoco: unknown | invalid coverage (non-numeric)')
|
||||
.get('/j/https/updates.jenkins-ci.org/job/hello-project/job/master.json')
|
||||
.intercept(nock =>
|
||||
nock('https://updates.jenkins-ci.org')
|
||||
.get(
|
||||
'/job/hello-project/job/master/lastBuild/jacoco/api/json?tree=instructionCoverage[covered,missed,percentage,total]'
|
||||
)
|
||||
.reply(200, {
|
||||
instructionCoverage: {
|
||||
covered: 39498,
|
||||
missed: 9508,
|
||||
percentage: 'non-numeric',
|
||||
percentageFloat: 80.5983,
|
||||
total: 49006,
|
||||
},
|
||||
})
|
||||
)
|
||||
.expectJSONTypes({ name: 'coverage', value: 'unknown' })
|
||||
|
||||
t.create('jacoco: unknown | exception handling')
|
||||
.get('/j/https/updates.jenkins-ci.org/job/hello-project/job/master.json')
|
||||
.intercept(nock =>
|
||||
nock('https://updates.jenkins-ci.org')
|
||||
.get(
|
||||
'/job/hello-project/job/master/lastBuild/jacoco/api/json?tree=instructionCoverage[covered,missed,percentage,total]'
|
||||
)
|
||||
.reply(200, {
|
||||
instructionCoverage: {
|
||||
covered: 39498,
|
||||
missed: 9508,
|
||||
percentage: '81.x',
|
||||
percentageFloat: 80.5983,
|
||||
total: 49006,
|
||||
},
|
||||
})
|
||||
)
|
||||
.expectJSONTypes({ name: 'coverage', value: 'unknown' })
|
||||
Reference in New Issue
Block a user