Repository: badges/shields (js)

Issue:
Code coverage color doesn't correspond to colors of Scrutinizer badge
On Scrutinizer 40% coverage is displayed as red with native badge and yellow with shields.io.
Would be better to have consistent colors here.


Pull request description:
refactor [Scrutinizer]
- Split service classes into separate files
- Quality badge was given explicit route (`/scrutinizer/quality/...`) with a redirector added to maintain any existing users of the old route
- Updated color scale used for coverage badge to match Scrutinizer scale (close #735)
  - < 40% is red, between 40% and 60% (inclusive) is yellow, > 60% is bright green (scale displayed on reports like [this](https://scrutinizer-ci.com/g/filp/whoops/code-structure/master/code-coverage))

Patch:
diff --git a/services/scrutinizer/scrutinizer-base.js b/services/scrutinizer/scrutinizer-base.js
new file mode 100644
index 0000000000..ffff327d81
--- /dev/null
+++ b/services/scrutinizer/scrutinizer-base.js
@@ -0,0 +1,42 @@
+'use strict'
+
+const { BaseJsonService, NotFound } = require('..')
+
+module.exports = class ScrutinizerBase extends BaseJsonService {
+  // https://scrutinizer-ci.com/docs/api/#repository-details
+  async fetch({ schema, vcs, slug }) {
+    return this._requestJson({
+      schema,
+      url: `https://scrutinizer-ci.com/api/repositories/${vcs}/${slug}`,
+      errorMessages: {
+        401: 'not authorized to access project',
+        404: 'project not found',
+      },
+    })
+  }
+
+  transformBranchInfo({ json, wantedBranch }) {
+    if (!wantedBranch) {
+      return json.applications[json.default_branch]
+    }
+
+    const branch = json.applications[wantedBranch]
+    if (!branch) {
+      throw new NotFound({ prettyMessage: ' branch not found' })
+    }
+
+    return branch
+  }
+
+  transformBranchInfoMetricValue({ json, branch, metric }) {
+    const {
+      index: {
+        _embedded: {
+          project: { metric_values: metricValues },
+        },
+      },
+    } = this.transformBranchInfo({ json, wantedBranch: branch })
+
+    return { value: metricValues[metric] }
+  }
+}
diff --git a/services/scrutinizer/scrutinizer-build.service.js b/services/scrutinizer/scrutinizer-build.service.js
new file mode 100644
index 0000000000..50e04e2832
--- /dev/null
+++ b/services/scrutinizer/scrutinizer-build.service.js
@@ -0,0 +1,128 @@
+'use strict'
+
+const Joi = require('joi')
+const { isBuildStatus, renderBuildStatusBadge } = require('../build-status')
+const ScrutinizerBase = require('./scrutinizer-base')
+
+const schema = Joi.object({
+  default_branch: Joi.string().required(),
+  applications: Joi.object()
+    .pattern(
+      /^/,
+      Joi.object({
+        build_status: Joi.object({
+          status: Joi.alternatives().try(isBuildStatus, Joi.equal('unknown')),
+        }).required(),
+      })
+    )
+    .required(),
+}).required()
+
+class ScrutinizerBuildBase extends ScrutinizerBase {
+  static get category() {
+    return 'build'
+  }
+
+  static get defaultBadgeData() {
+    return {
+      label: 'build',
+    }
+  }
+
+  async makeBadge({ vcs, slug, branch }) {
+    const json = await this.fetch({ schema, vcs, slug })
+    const {
+      build_status: { status },
+    } = this.transformBranchInfo({ json, wantedBranch: branch })
+    return renderBuildStatusBadge({ status })
+  }
+}
+
+class ScrutinizerBuild extends ScrutinizerBuildBase {
+  static get route() {
+    return {
+      base: 'scrutinizer/build',
+      pattern: ':vcs(g|b)/:user/:repo/:branch*',
+    }
+  }
+
+  static get examples() {
+    return [
+      {
+        title: 'Scrutinizer build (GitHub/Bitbucket)',
+        pattern: ':vcs(g|b)/:user/:repo/:branch?',
+        namedParams: {
+          vcs: 'g',
+          user: 'filp',
+          repo: 'whoops',
+          branch: 'master',
+        },
+        staticPreview: renderBuildStatusBadge({ status: 'passing' }),
+      },
+    ]
+  }
+
+  async handle({ vcs, user, repo, branch }) {
+    return this.makeBadge({
+      vcs,
+      slug: `${user}/${repo}`,
+      branch,
+    })
+  }
+}
+
+class ScrutinizerGitLabBuild extends ScrutinizerBuildBase {
+  static get route() {
+    return {
+      base: 'scrutinizer/build/gl',
+      pattern: ':instance/:user/:repo/:branch*',
+    }
+  }
+
+  static get examples() {
+    // There are no known anonymous accessible Scrutinizer reports available for GitLab repos.
+    // The example used is valid, but the project will not be accessible if Shields users try to use it.
+    // https://gitlab.propertywindow.nl/propertywindow/client
+    // https://scrutinizer-ci.com/gl/propertywindow/propertywindow/client/badges/quality-score.png?b=master&s=dfae6992a48184cc2333b4c349cec0447f0d67c2
+    return [
+      {
+        title: 'Scrutinizer build (GitLab)',
+        pattern: ':instance/:user/:repo/:branch?',
+        namedParams: {
+          instance: 'propertywindow',
+          user: 'propertywindow',
+          repo: 'client',
+          branch: 'master',
+        },
+        staticPreview: renderBuildStatusBadge({ status: 'passing' }),
+      },
+    ]
+  }
+
+  async handle({ instance, user, repo, branch }) {
+    return this.makeBadge({
+      vcs: 'gl',
+      slug: `${instance}/${user}/${repo}`,
+      branch,
+    })
+  }
+}
+
+class ScrutinizerPlainGitBuild extends ScrutinizerBuildBase {
+  static get route() {
+    return {
+      base: 'scrutinizer/build/gp',
+      pattern: ':slug/:branch*',
+    }
+  }
+
+  async handle({ slug, branch }) {
+    return this.makeBadge({ vcs: 'gp', slug, branch })
+  }
+}
+
+module.exports = [
+  ScrutinizerBuild,
+  ScrutinizerGitLabBuild,
+  ScrutinizerPlainGitBuild,
+]
diff --git a/services/scrutinizer/scrutinizer-coverage.service.js b/services/scrutinizer/scrutinizer-coverage.service.js
new file mode 100644
index 0000000000..1af74c7c9e
--- /dev/null
+++ b/services/scrutinizer/scrutinizer-coverage.service.js
@@ -0,0 +1,160 @@
+'use strict'
+
+const Joi = require('joi')
+const { NotFound } = require('..')
+const { colorScale } = require('../color-formatters')
+const ScrutinizerBase = require('./scrutinizer-base')
+
+const schema = Joi.object({
+  default_branch: Joi.string().required(),
+  applications: Joi.object()
+    .pattern(
+      /^/,
+      Joi.object({
+        index: Joi.object({
+          _embedded: Joi.object({
+            project: Joi.object({
+              metric_values: Joi.object({
+                'scrutinizer.test_coverage': Joi.number().positive(),
+              }).required(),
+            }).required(),
+          }).required(),
+        }).required(),
+      })
+    )
+    .required(),
+}).required()
+
+// https://scrutinizer-ci.com/g/filp/whoops/code-structure/master/code-coverage
+// < 40% - red
+// 40-60% (inclusive) - yellow
+// > 60% brightgreen
+const scale = colorScale([40, 61], ['red', 'yellow', 'brightgreen'])
+
+class ScrutinizerCoverageBase extends ScrutinizerBase {
+  static get category() {
+    return 'coverage'
+  }
+
+  static get defaultBadgeData() {
+    return {
+      label: 'coverage',
+    }
+  }
+
+  static render({ coverage }) {
+    return {
+      message: `${coverage.toFixed(0)}%`,
+      color: scale(coverage),
+    }
+  }
+
+  transform({ json, branch }) {
+    const { value: rawCoverage } = this.transformBranchInfoMetricValue({
+      json,
+      branch,
+      metric: 'scrutinizer.test_coverage',
+    })
+
+    if (!rawCoverage) {
+      throw new NotFound({ prettyMessage: 'coverage not found' })
+    }
+
+    return { coverage: rawCoverage * 100 }
+  }
+
+  async makeBadge({ vcs, slug, branch }) {
+    const json = await this.fetch({ schema, vcs, slug })
+    const { coverage } = this.transform({ json, branch })
+    return this.constructor.render({ coverage })
+  }
+}
+
+class ScrutinizerCoverage extends ScrutinizerCoverageBase {
+  static get route() {
+    return {
+      base: 'scrutinizer/coverage',
+      pattern: ':vcs(g|b)/:user/:repo/:branch*',
+    }
+  }
+
+  static get examples() {
+    return [
+      {
+        title: 'Scrutinizer coverage (GitHub/BitBucket)',
+        pattern: ':vcs(g|b)/:user/:repo/:branch?',
+        namedParams: {
+          vcs: 'g',
+          user: 'filp',
+          repo: 'whoops',
+          branch: 'master',
+        },
+        staticPreview: this.render({ coverage: 86 }),
+      },
+    ]
+  }
+
+  async handle({ vcs, user, repo, branch }) {
+    return this.makeBadge({
+      vcs,
+      slug: `${user}/${repo}`,
+      branch,
+    })
+  }
+}
+
+class ScrutinizerCoverageGitLab extends ScrutinizerCoverageBase {
+  static get route() {
+    return {
+      base: 'scrutinizer/coverage/gl',
+      pattern: ':instance/:user/:repo/:branch*',
+    }
+  }
+
+  static get examples() {
+    // There are no known anonymous accessible Scrutinizer reports available for GitLab repos.
+    // The example used is valid, but the project will not be accessible if Shields users try to use it.
+    // https://gitlab.propertywindow.nl/propertywindow/client
+    // https://scrutinizer-ci.com/gl/propertywindow/propertywindow/client/badges/quality-score.png?b=master&s=dfae6992a48184cc2333b4c349cec0447f0d67c2
+    return [
+      {
+        title: 'Scrutinizer coverage (GitLab)',
+        pattern: ':instance/:user/:repo/:branch?',
+        namedParams: {
+          instance: 'propertywindow',
+          user: 'propertywindow',
+          repo: 'client',
+          branch: 'master',
+        },
+        staticPreview: this.render({ coverage: 94 }),
+      },
+    ]
+  }
+
+  async handle({ instance, user, repo, branch }) {
+    return this.makeBadge({
+      vcs: 'gl',
+      slug: `${instance}/${user}/${repo}`,
+      branch,
+    })
+  }
+}
+
+class ScrutinizerCoveragePlainGit extends ScrutinizerCoverageBase {
+  static get route() {
+    return {
+      base: 'scrutinizer/coverage/gp',
+      pattern: ':slug/:branch*',
+    }
+  }
+
+  async handle({ slug, branch }) {
+    return this.makeBadge({ vcs: 'gp', slug, branch })
+  }
+}
+
+module.exports = [
+  ScrutinizerCoverage,
+  ScrutinizerCoverageGitLab,
+  ScrutinizerCoveragePlainGit,
+]
diff --git a/services/scrutinizer/scrutinizer-coverage.spec.js b/services/scrutinizer/scrutinizer-coverage.spec.js
new file mode 100644
index 0000000000..b8361be325
--- /dev/null
+++ b/services/scrutinizer/scrutinizer-coverage.spec.js
@@ -0,0 +1,54 @@
+'use strict'
+
+const { expect } = require('chai')
+const { test, given } = require('sazerac')
+const { NotFound } = require('..')
+const [ScrutinizerCoverage] = require('./scrutinizer-coverage.service')
+
+describe('ScrutinizerCoverage', function() {
+  test(ScrutinizerCoverage.render, () => {
+    given({ coverage: 39 }).expect({
+      message: '39%',
+      color: 'red',
+    })
+    given({ coverage: 40 }).expect({
+      message: '40%',
+      color: 'yellow',
+    })
+    given({ coverage: 60 }).expect({
+      message: '60%',
+      color: 'yellow',
+    })
+    given({ coverage: 61 }).expect({
+      message: '61%',
+      color: 'brightgreen',
+    })
+  })
+
+  context('transform()', function() {
+    it('throws NotFound error when there is no coverage data', function() {
+      try {
+        ScrutinizerCoverage.prototype.transform({
+          branch: 'master',
+          json: {
+            applications: {
+              master: {
+                index: {
+                  _embedded: {
+                    project: {
+                      metric_values: {},
+                    },
+                  },
+                },
+              },
+            },
+          },
+        })
+        expect.fail('Expected to throw')
+      } catch (e) {
+        expect(e).to.be.an.instanceof(NotFound)
+        expect(e.prettyMessage).to.equal('coverage not found')
+      }
+    })
+  })
+})
diff --git a/services/scrutinizer/scrutinizer-quality.service.js b/services/scrutinizer/scrutinizer-quality.service.js
new file mode 100644
index 0000000000..50b6e32751
--- /dev/null
+++ b/services/scrutinizer/scrutinizer-quality.service.js
@@ -0,0 +1,148 @@
+'use strict'
+
+const Joi = require('joi')
+const { colorScale } = require('../color-formatters')
+const ScrutinizerBase = require('./scrutinizer-base')
+
+const schema = Joi.object({
+  default_branch: Joi.string().required(),
+  applications: Joi.object()
+    .pattern(
+      /^/,
+      Joi.object({
+        index: Joi.object({
+          _embedded: Joi.object({
+            project: Joi.object({
+              metric_values: Joi.object({
+                'scrutinizer.quality': Joi.number().positive(),
+              }).required(),
+            }).required(),
+          }).required(),
+        }).required(),
+      })
+    )
+    .required(),
+}).required()
+
+const scale = colorScale(
+  [4, 5, 7, 9],
+  ['red', 'orange', 'yellow', 'green', 'brightgreen']
+)
+
+class ScrutinizerQualityBase extends ScrutinizerBase {
+  static get category() {
+    return 'analysis'
+  }
+
+  static get defaultBadgeData() {
+    return {
+      label: 'code quality',
+    }
+  }
+
+  static render({ score }) {
+    return {
+      message: `${Math.round(score * 100) / 100}`,
+      color: scale(score),
+    }
+  }
+
+  async makeBadge({ vcs, slug, branch }) {
+    const json = await this.fetch({ schema, vcs, slug })
+    const { value: score } = this.transformBranchInfoMetricValue({
+      json,
+      branch,
+      metric: 'scrutinizer.quality',
+    })
+    return this.constructor.render({ score })
+  }
+}
+
+class ScrutinizerQuality extends ScrutinizerQualityBase {
+  static get route() {
+    return {
+      base: 'scrutinizer/quality',
+      pattern: ':vcs(g|b)/:user/:repo/:branch*',
+    }
+  }
+
+  static get examples() {
+    return [
+      {
+        title: 'Scrutinizer code quality (GitHub/Bitbucket)',
+        pattern: ':vcs(g|b)/:user/:repo/:branch?',
+        namedParams: {
+          vcs: 'g',
+          user: 'filp',
+          repo: 'whoops',
+          branch: 'master',
+        },
+        staticPreview: this.render({ score: 8.26 }),
+      },
+    ]
+  }
+
+  async handle({ vcs, user, repo, branch }) {
+    return this.makeBadge({
+      vcs,
+      slug: `${user}/${repo}`,
+      branch,
+    })
+  }
+}
+
+class ScrutinizerQualityGitLab extends ScrutinizerQualityBase {
+  static get route() {
+    return {
+      base: 'scrutinizer/quality/gl',
+      pattern: ':instance/:user/:repo/:branch*',
+    }
+  }
+
+  static get examples() {
+    // There are no known anonymous accessible Scrutinizer reports available for GitLab repos.
+    // The example used is valid, but the project will not be accessible if Shields users try to use it.
+    // https://gitlab.propertywindow.nl/propertywindow/client
+    // https://scrutinizer-ci.com/gl/propertywindow/propertywindow/client/badges/quality-score.png?b=master&s=dfae6992a48184cc2333b4c349cec0447f0d67c2
+    return [
+      {
+        title: 'Scrutinizer coverage (GitLab)',
+        pattern: ':instance/:user/:repo/:branch?',
+        namedParams: {
+          instance: 'propertywindow',
+          user: 'propertywindow',
+          repo: 'client',
+          branch: 'master',
+        },
+        staticPreview: this.render({ score: 10.0 }),
+      },
+    ]
+  }
+
+  async handle({ instance, user, repo, branch }) {
+    return this.makeBadge({
+      vcs: 'gl',
+      slug: `${instance}/${user}/${repo}`,
+      branch,
+    })
+  }
+}
+
+class ScrutinizerQualityPlainGit extends ScrutinizerQualityBase {
+  static get route() {
+    return {
+      base: 'scrutinizer/quality/gp',
+      pattern: ':slug/:branch*',
+    }
+  }
+
+  async handle({ slug, branch }) {
+    return this.makeBadge({ vcs: 'gp', slug, branch })
+  }
+}
+
+module.exports = [
+  ScrutinizerQuality,
+  ScrutinizerQualityGitLab,
+  ScrutinizerQualityPlainGit,
+]
diff --git a/services/scrutinizer/scrutinizer-redirect.service.js b/services/scrutinizer/scrutinizer-redirect.service.js
new file mode 100644
index 0000000000..4aee6a5ebf
--- /dev/null
+++ b/services/scrutinizer/scrutinizer-redirect.service.js
@@ -0,0 +1,42 @@
+'use strict'
+
+const { redirector } = require('..')
+
+const commonAttrs = {
+  category: 'analysis',
+  dateAdded: new Date('2019-04-24'),
+}
+
+module.exports = [
+  redirector({
+    route: {
+      base: 'scrutinizer',
+      pattern: ':vcs(g|b)/:user/:repo/:branch*',
+    },
+    transformPath: ({ vcs, user, repo, branch }) =>
+      `/scrutinizer/quality/${vcs}/${user}/${repo}${
+        branch ? `/${branch}` : ''
+      }`,
+    ...commonAttrs,
+  }),
+  redirector({
+    route: {
+      base: 'scrutinizer/gl',
+      pattern: ':instance/:user/:repo/:branch*',
+    },
+    transformPath: ({ instance, user, repo, branch }) =>
+      `/scrutinizer/quality/gl/${instance}/${user}/${repo}${
+        branch ? `/${branch}` : ''
+      }`,
+    ...commonAttrs,
+  }),
+  redirector({
+    route: {
+      base: 'scrutinizer/gp',
+      pattern: ':slug/:branch*',
+    },
+    transformPath: ({ slug, branch }) =>
+      `/scrutinizer/quality/gp/${slug}${branch ? `/${branch}` : ''}`,
+    ...commonAttrs,
+  }),
+]
diff --git a/services/scrutinizer/scrutinizer.service.js b/services/scrutinizer/scrutinizer.service.js
deleted file mode 100644
index 9b433d737c..0000000000
--- a/services/scrutinizer/scrutinizer.service.js
+++ /dev/null
@@ -1,208 +0,0 @@
-'use strict'
-
-const LegacyService = require('../legacy-service')
-const { makeBadgeData: getBadgeData } = require('../../lib/badge-data')
-const { checkErrorResponse } = require('../../lib/error-helper')
-const {
-  coveragePercentage: coveragePercentageColor,
-} = require('../color-formatters')
-
-class ScrutinizerBuild extends LegacyService {
-  static get category() {
-    return 'build'
-  }
-
-  static get route() {
-    return {
-      base: 'scrutinizer/build',
-      pattern: ':vcsType/:user/:repo',
-    }
-  }
-
-  static get examples() {
-    return [
-      {
-        title: 'Scrutinizer build',
-        namedParams: {
-          vcsType: 'g',
-          user: 'filp',
-          repo: 'whoops',
-        },
-        staticPreview: {
-          label: 'build',
-          message: 'passing',
-          color: 'brightgreen',
-        },
-      },
-    ]
-  }
-
-  static registerLegacyRouteHandler({ camp, cache }) {}
-}
-
-class ScrutinizerCoverage extends LegacyService {
-  static get category() {
-    return 'coverage'
-  }
-
-  static get route() {
-    return {
-      base: 'scrutinizer/coverage',
-      pattern: ':vcsType/:user/:repo/:branch*',
-    }
-  }
-
-  static get examples() {
-    return [
-      {
-        title: 'Scrutinizer coverage',
-        pattern: ':vcsType/:user/:repo',
-        namedParams: {
-          vcsType: 'g',
-          user: 'filp',
-          repo: 'whoops',
-        },
-        staticPreview: {
-          label: 'coverage',
-          message: '56%',
-          color: 'yellow',
-        },
-      },
-      {
-        title: 'Scrutinizer coverage (branch)',
-        pattern: ':vcsType/:user/:repo/:branch',
-        namedParams: {
-          vcsType: 'g',
-          user: 'doctrine',
-          repo: 'orm',
-          branch: 'master',
-        },
-        staticPreview: {
-          label: 'coverage',
-          message: '73%',
-          color: 'yellow',
-        },
-      },
-    ]
-  }
-
-  static registerLegacyRouteHandler({ camp, cache }) {}
-}
-
-// This legacy service should be rewritten to use e.g. BaseJsonService.
-//
-// Tips for rewriting:
-// https://github.com/badges/shields/blob/master/doc/rewriting-services.md
-//
-// Do not base new services on this code.
-class Scrutinizer extends LegacyService {
-  static get category() {
-    return 'analysis'
-  }
-
-  static get route() {
-    return {
-      base: 'scrutinizer',
-      pattern: ':vcsType/:user/:repo',
-    }
-  }
-
-  static get examples() {
-    return [
-      {
-        title: 'Scrutinizer code quality',
-        namedParams: {
-          vcsType: 'g',
-          user: 'filp',
-          repo: 'whoops',
-        },
-        staticPreview: {
-          label: 'code quality',
-          message: '8.26',
-          color: 'green',
-        },
-      },
-    ]
-  }
-
-  static registerLegacyRouteHandler({ camp, cache }) {
-    camp.route(
-      /^\/scrutinizer(?:\/(build|coverage))?\/([^/]+\/[^/]+\/[^/]+|gp\/[^/])(?:\/(.+))?\.(svg|png|gif|jpg|json)$/,
-      cache((data, match, sendBadge, request) => {
-        const type = match[1] ? match[1] : 'code quality'
-        const repo = match[2] // eg, g/phpmyadmin/phpmyadmin
-        let branch = match[3]
-        const format = match[4]
-        const apiUrl = `https://scrutinizer-ci.com/api/repositories/${repo}`
-        const badgeData = getBadgeData(type, data)
-        request(apiUrl, {}, (err, res, buffer) => {
-          if (
-            checkErrorResponse(badgeData, err, res, {
-              404: 'project or branch not found',
-            })
-          ) {
-            sendBadge(format, badgeData)
-            return
-          }
-          try {
-            const parsedData = JSON.parse(buffer)
-            // Which branch are we dealing with?
-            if (branch === undefined) {
-              branch = parsedData.default_branch
-            }
-            if (type === 'coverage') {
-              const percentage =
-                parsedData.applications[branch].index._embedded.project
-                  .metric_values['scrutinizer.test_coverage'] * 100
-              if (isNaN(percentage)) {
-                badgeData.text[1] = 'unknown'
-                badgeData.colorscheme = 'gray'
-              } else {
-                badgeData.text[1] = `${percentage.toFixed(0)}%`
-                badgeData.colorscheme = coveragePercentageColor(percentage)
-              }
-            } else if (type === 'build') {
-              const status = parsedData.applications[branch].build_status.status
-              badgeData.text[1] = status
-              if (status === 'passed') {
-                badgeData.colorscheme = 'brightgreen'
-                badgeData.text[1] = 'passing'
-              } else if (status === 'failed' || status === 'error') {
-                badgeData.colorscheme = 'red'
-              } else if (status === 'pending') {
-                badgeData.colorscheme = 'orange'
-              }
-            } else {
-              let score =
-                parsedData.applications[branch].index._embedded.project
-                  .metric_values['scrutinizer.quality']
-              score = Math.round(score * 100) / 100
-              badgeData.text[1] = score
-              if (score > 9) {
-                badgeData.colorscheme = 'brightgreen'
-              } else if (score > 7) {
-                badgeData.colorscheme = 'green'
-              } else if (score > 5) {
-                badgeData.colorscheme = 'yellow'
-              } else if (score > 4) {
-                badgeData.colorscheme = 'orange'
-              } else {
-                badgeData.colorscheme = 'red'
-              }
-            }
-            sendBadge(format, badgeData)
-          } catch (e) {
-            badgeData.text[1] = 'invalid'
-            sendBadge(format, badgeData)
-          }
-        })
-      })
-    )
-  }
-}
-
-module.exports = {
-  ScrutinizerBuild,
-  ScrutinizerCoverage,
-  Scrutinizer,
-}


Test patch:
diff --git a/services/scrutinizer/scrutinizer-build.tester.js b/services/scrutinizer/scrutinizer-build.tester.js
new file mode 100644
index 0000000000..aaa3a5bd96
--- /dev/null
+++ b/services/scrutinizer/scrutinizer-build.tester.js
@@ -0,0 +1,74 @@
+'use strict'
+
+const Joi = require('joi')
+const { isBuildStatus } = require('../build-status')
+const { ServiceTester } = require('../tester')
+const t = (module.exports = new ServiceTester({
+  id: 'ScrutinizerBuild',
+  title: 'ScrutinizerBuild',
+  pathPrefix: '/scrutinizer/build',
+}))
+
+t.create('build (GitHub)')
+  .get('/g/filp/whoops.json')
+  .expectBadge({
+    label: 'build',
+    message: Joi.alternatives().try(isBuildStatus, Joi.equal('unknown')),
+  })
+
+t.create('build (Bitbucket)')
+  .get('/b/atlassian/python-bitbucket.json')
+  .expectBadge({
+    label: 'build',
+    message: Joi.alternatives().try(isBuildStatus, Joi.equal('unknown')),
+  })
+
+t.create('build (branch)')
+  .get('/g/phpmyadmin/phpmyadmin/master.json')
+  .expectBadge({
+    label: 'build',
+    message: Joi.alternatives().try(isBuildStatus, Joi.equal('unknown')),
+  })
+
+t.create('build - unknown status')
+  .get('/g/filp/whoops.json')
+  .intercept(nock =>
+    nock('https://scrutinizer-ci.com')
+      .get('/api/repositories/g/filp/whoops')
+      .reply(200, {
+        default_branch: 'master',
+        applications: {
+          master: {
+            build_status: {
+              status: 'unknown',
+            },
+          },
+        },
+      })
+  )
+  .expectBadge({
+    label: 'build',
+    message: 'unknown',
+    color: 'lightgrey',
+  })
+
+t.create('build private project')
+  .get('/gl/propertywindow/propertywindow/client.json')
+  .expectBadge({
+    label: 'build',
+    message: 'not authorized to access project',
+  })
+
+t.create('build nonexistent project')
+  .get('/gp/foo.json')
+  .expectBadge({
+    label: 'build',
+    message: 'project not found',
+  })
+
+t.create('build nonexistent branch')
+  .get('/g/phpmyadmin/phpmyadmin/super-fake/not-real-branch.json')
+  .expectBadge({
+    label: 'build',
+    message: 'branch not found',
+  })
diff --git a/services/scrutinizer/scrutinizer-coverage.tester.js b/services/scrutinizer/scrutinizer-coverage.tester.js
new file mode 100644
index 0000000000..bfe306b34d
--- /dev/null
+++ b/services/scrutinizer/scrutinizer-coverage.tester.js
@@ -0,0 +1,44 @@
+'use strict'
+
+const { isIntegerPercentage } = require('../test-validators')
+const { ServiceTester } = require('../tester')
+const t = (module.exports = new ServiceTester({
+  id: 'ScrutinizerCoverage',
+  title: 'ScrutinizerCoverage',
+  pathPrefix: '/scrutinizer/coverage',
+}))
+
+t.create('code coverage (GitHub)')
+  .get('/g/filp/whoops.json')
+  .expectBadge({
+    label: 'coverage',
+    message: isIntegerPercentage,
+  })
+
+t.create('code coverage branch (GitHub)')
+  .get('/g/PHPMailer/PHPMailer/master.json')
+  .expectBadge({
+    label: 'coverage',
+    message: isIntegerPercentage,
+  })
+
+t.create('code coverage (Bitbucket)')
+  .get('/b/atlassian/python-bitbucket.json')
+  .expectBadge({
+    label: 'coverage',
+    message: isIntegerPercentage,
+  })
+
+t.create('code coverage private project')
+  .get('/gl/propertywindow/propertywindow/client.json')
+  .expectBadge({
+    label: 'coverage',
+    message: 'not authorized to access project',
+  })
+
+t.create('code coverage nonexistent project')
+  .get('/gp/foo.json')
+  .expectBadge({
+    label: 'coverage',
+    message: 'project not found',
+  })
diff --git a/services/scrutinizer/scrutinizer-quality.tester.js b/services/scrutinizer/scrutinizer-quality.tester.js
new file mode 100644
index 0000000000..48ce6d198c
--- /dev/null
+++ b/services/scrutinizer/scrutinizer-quality.tester.js
@@ -0,0 +1,46 @@
+'use strict'
+
+const Joi = require('joi')
+const { ServiceTester } = require('../tester')
+const t = (module.exports = new ServiceTester({
+  id: 'ScrutinizerQuality',
+  title: 'ScrutinizerQuality',
+  pathPrefix: '/scrutinizer/quality',
+}))
+
+const isQualityNumber = Joi.number().positive()
+
+t.create('code quality (GitHub)')
+  .get('/g/filp/whoops.json')
+  .expectBadge({
+    label: 'code quality',
+    message: isQualityNumber,
+  })
+
+t.create('code quality branch (GitHub)')
+  .get('/g/PHPMailer/PHPMailer/master.json')
+  .expectBadge({
+    label: 'code quality',
+    message: isQualityNumber,
+  })
+
+t.create('code quality (Bitbucket)')
+  .get('/b/atlassian/python-bitbucket.json')
+  .expectBadge({
+    label: 'code quality',
+    message: isQualityNumber,
+  })
+
+t.create('code quality private project')
+  .get('/gl/propertywindow/propertywindow/client.json')
+  .expectBadge({
+    label: 'code quality',
+    message: 'not authorized to access project',
+  })
+
+t.create('code quality nonexistent project')
+  .get('/gp/foo.json')
+  .expectBadge({
+    label: 'code quality',
+    message: 'project not found',
+  })
diff --git a/services/scrutinizer/scrutinizer-redirect.tester.js b/services/scrutinizer/scrutinizer-redirect.tester.js
new file mode 100644
index 0000000000..28c5c5b892
--- /dev/null
+++ b/services/scrutinizer/scrutinizer-redirect.tester.js
@@ -0,0 +1,71 @@
+'use strict'
+
+const { ServiceTester } = require('../tester')
+
+const t = (module.exports = new ServiceTester({
+  id: 'ScrutinizerQualityRedirect',
+  title: 'ScrutinizerQualityRedirect',
+  pathPrefix: '/scrutinizer',
+}))
+
+t.create('scrutinizer quality GitHub')
+  .get('/g/doctrine/orm.svg', {
+    followRedirect: false,
+  })
+  .expectStatus(301)
+  .expectHeader('Location', '/scrutinizer/quality/g/doctrine/orm.svg')
+
+t.create('scrutinizer quality GitHub (branch)')
+  .get('/g/doctrine/orm/develop.svg', {
+    followRedirect: false,
+  })
+  .expectStatus(301)
+  .expectHeader('Location', '/scrutinizer/quality/g/doctrine/orm/develop.svg')
+
+t.create('scrutinizer quality Bitbucket')
+  .get('/b/doctrine/orm.svg', {
+    followRedirect: false,
+  })
+  .expectStatus(301)
+  .expectHeader('Location', '/scrutinizer/quality/b/doctrine/orm.svg')
+
+t.create('scrutinizer quality Bitbucket (branch)')
+  .get('/b/atlassian/python-bitbucket/develop.svg', {
+    followRedirect: false,
+  })
+  .expectStatus(301)
+  .expectHeader(
+    'Location',
+    '/scrutinizer/quality/b/atlassian/python-bitbucket/develop.svg'
+  )
+
+t.create('scrutinizer quality GitLab')
+  .get('/gl/gitlab-com/foo/bar.svg', {
+    followRedirect: false,
+  })
+  .expectStatus(301)
+  .expectHeader('Location', '/scrutinizer/quality/gl/gitlab-com/foo/bar.svg')
+
+t.create('scrutinizer quality GitLab (branch)')
+  .get('/gl/gitlab-com/foo/bar/develop.svg', {
+    followRedirect: false,
+  })
+  .expectStatus(301)
+  .expectHeader(
+    'Location',
+    '/scrutinizer/quality/gl/gitlab-com/foo/bar/develop.svg'
+  )
+
+t.create('scrutinizer quality Plain Git')
+  .get('/gp/bar.svg', {
+    followRedirect: false,
+  })
+  .expectStatus(301)
+  .expectHeader('Location', '/scrutinizer/quality/gp/bar.svg')
+
+t.create('scrutinizer quality Plain Git (branch)')
+  .get('/gp/bar/develop.svg', {
+    followRedirect: false,
+  })
+  .expectStatus(301)
+  .expectHeader('Location', '/scrutinizer/quality/gp/bar/develop.svg')
diff --git a/services/scrutinizer/scrutinizer.tester.js b/services/scrutinizer/scrutinizer.tester.js
deleted file mode 100644
index 8dff6ccda7..0000000000
--- a/services/scrutinizer/scrutinizer.tester.js
+++ /dev/null
@@ -1,101 +0,0 @@
-'use strict'
-
-const Joi = require('joi')
-const { isBuildStatus } = require('../build-status')
-const { ServiceTester } = require('../tester')
-const { isIntegerPercentage } = require('../test-validators')
-
-const t = (module.exports = new ServiceTester({
-  id: 'scrutinizer',
-  title: 'Scrutinizer',
-}))
-
-t.create('code quality')
-  .get('/g/filp/whoops.json')
-  .expectBadge({
-    label: 'code quality',
-    message: Joi.number().positive(),
-  })
-
-t.create('code quality (branch)')
-  .get('/g/phpmyadmin/phpmyadmin/master.json')
-  .expectBadge({
-    label: 'code quality',
-    message: Joi.number().positive(),
-  })
-
-t.create('code coverage')
-  .get('/coverage/g/filp/whoops.json')
-  .expectBadge({
-    label: 'coverage',
-    message: isIntegerPercentage,
-  })
-
-t.create('code coverage (branch)')
-  .get('/coverage/g/PHPMailer/PHPMailer/master.json')
-  .expectBadge({
-    label: 'coverage',
-    message: isIntegerPercentage,
-  })
-
-t.create('build')
-  .get('/build/g/filp/whoops.json')
-  .expectBadge({
-    label: 'build',
-    message: Joi.alternatives().try(isBuildStatus, Joi.equal('unknown')),
-  })
-
-t.create('build (branch)')
-  .get('/build/g/phpmyadmin/phpmyadmin/master.json')
-  .expectBadge({
-    label: 'build',
-    message: Joi.alternatives().try(isBuildStatus, Joi.equal('unknown')),
-  })
-
-t.create('project not found')
-  .get('/build/g/does-not-exist/does-not-exist.json')
-  .expectBadge({
-    label: 'build',
-    message: 'project or branch not found',
-  })
-
-t.create('code coverage unknown')
-  .get('/coverage/g/phpmyadmin/phpmyadmin/master.json')
-  .expectBadge({
-    label: 'coverage',
-    message: 'unknown',
-  })
-
-t.create('unexpected response data')
-  .get('/coverage/g/filp/whoops.json')
-  .intercept(nock =>
-    nock('https://scrutinizer-ci.com')
-      .get('/api/repositories/g/filp/whoops')
-      .reply(200, '{"unexpected":"data"}')
-  )
-  .expectBadge({
-    label: 'coverage',
-    message: 'invalid',
-  })
-
-t.create('build - unknown')
-  .get('/build/g/filp/whoops.json')
-  .intercept(nock =>
-    nock('https://scrutinizer-ci.com')
-      .get('/api/repositories/g/filp/whoops')
-      .reply(200, {
-        default_branch: 'master',
-        applications: {
-          master: {
-            build_status: {
-              status: 'unknown',
-            },
-          },
-        },
-      })
-  )
-  .expectBadge({
-    label: 'build',
-    message: 'unknown',
-    color: 'lightgrey',
-  })