Repository: badges/shields (js)

Issue:
Badge Request: AppVeyor Job Level Status Badges
:clipboard: **Description**
I'm using dynamic badges to display the status of separate AppVeyor jobs since AppVeyor does not support per-job status badges. It is working cool, here is an example ![Windows build status](https://img.shields.io/badge/dynamic/json?color=informational&label=Windows&query=%24.build.jobs%5B%3F%28%40.name%20%3D%3D%3D%20%22Windows%22%29%5D.status&url=https%3A%2F%2Fci.appveyor.com%2Fapi%2Fprojects%2Fwpmgprostotema%2Fvoicetranscoder%2Fbranch%2Fmaster).

The only problem the color is static, it does not indicate the build status (e.g. green for success, red for failure). I hope it can be made dynamic.

There are two options that I see:
- Add `colorQuery` parameter, but the problem here is that JSON does not contain a color name to be displayed, so this is not appropriate option.
- Add `message-to-color-map` parameter. I think its value should be JSON object, for example:
```JavaScript
{
  "success": "success",
  "failed": "critical",
  "<some placeholder to match other values>": "inactive"
}
```

The only problem here is placeholder value, I hope we can just use empty string for it. But I think it would be better to just use current `color` parameter as fallback.

So, the final idea:
- Add `message-to-color-map` parameter, if the message matches one of the key then use the matched value as badge color.
- If it does not match any key then fallback to the `color` parameter.

<!-- A clear and concise description of the new feature. -->

<!-- Love Shields? Please consider donating $10 to sustain our activities:
👉  https://opencollective.com/shields -->


Pull request description:
add [AppVeyorJobBuild] status badge
Resolves #4393 

Patch:
diff --git a/frontend/lib/service-definitions/index.spec.ts b/frontend/lib/service-definitions/index.spec.ts
index b83c603b9b..5581caef40 100644
--- a/frontend/lib/service-definitions/index.spec.ts
+++ b/frontend/lib/service-definitions/index.spec.ts
@@ -11,6 +11,6 @@ describe('Service definition helpers', function() {
   it('getDefinitionsForCategory', function() {
     expect(getDefinitionsForCategory('build'))
       .to.have.length.greaterThan(10)
-      .and.lessThan(50)
+      .and.lessThan(75)
   })
 })
diff --git a/services/appveyor/appveyor-base.js b/services/appveyor/appveyor-base.js
index 23cf51d57b..e97d3a38a5 100644
--- a/services/appveyor/appveyor-base.js
+++ b/services/appveyor/appveyor-base.js
@@ -10,6 +10,10 @@ const schema = Joi.object({
     status: isBuildStatus,
     jobs: Joi.array()
       .items({
+        name: Joi.string()
+          .allow('')
+          .required(),
+        status: isBuildStatus,
         testsCount: nonNegativeInteger,
         passedTestsCount: nonNegativeInteger,
         failedTestsCount: nonNegativeInteger,
diff --git a/services/appveyor/appveyor-build-redirect.service.js b/services/appveyor/appveyor-build-redirect.service.js
new file mode 100644
index 0000000000..df72345999
--- /dev/null
+++ b/services/appveyor/appveyor-build-redirect.service.js
@@ -0,0 +1,16 @@
+'use strict'
+
+const { redirector } = require('..')
+
+module.exports = [
+  redirector({
+    category: 'build',
+    route: {
+      base: 'appveyor/ci',
+      pattern: ':user/:repo/:branch*',
+    },
+    transformPath: ({ user, repo, branch }) =>
+      `/appveyor/build/${user}/${repo}${branch ? `/${branch}` : ''}`,
+    dateAdded: new Date('2019-12-10'),
+  }),
+]
diff --git a/services/appveyor/appveyor-ci.service.js b/services/appveyor/appveyor-build.service.js
similarity index 90%
rename from services/appveyor/appveyor-ci.service.js
rename to services/appveyor/appveyor-build.service.js
index 1e3dc87b08..443ef7544e 100644
--- a/services/appveyor/appveyor-ci.service.js
+++ b/services/appveyor/appveyor-build.service.js
@@ -3,9 +3,9 @@
 const { renderBuildStatusBadge } = require('../build-status')
 const AppVeyorBase = require('./appveyor-base')
 
-module.exports = class AppVeyorCi extends AppVeyorBase {
+module.exports = class AppVeyorBuild extends AppVeyorBase {
   static get route() {
-    return this.buildRoute('appveyor/ci')
+    return this.buildRoute('appveyor/build')
   }
 
   static get examples() {
diff --git a/services/appveyor/appveyor-ci.tester.js b/services/appveyor/appveyor-build.tester.js
similarity index 100%
rename from services/appveyor/appveyor-ci.tester.js
rename to services/appveyor/appveyor-build.tester.js
diff --git a/services/appveyor/appveyor-job-build.service.js b/services/appveyor/appveyor-job-build.service.js
new file mode 100644
index 0000000000..8fe6e454ef
--- /dev/null
+++ b/services/appveyor/appveyor-job-build.service.js
@@ -0,0 +1,68 @@
+'use strict'
+
+const { renderBuildStatusBadge } = require('../build-status')
+const AppVeyorBase = require('./appveyor-base')
+const { NotFound } = require('..')
+
+module.exports = class AppVeyorJobBuild extends AppVeyorBase {
+  static get route() {
+    return {
+      base: 'appveyor/job/build',
+      pattern: ':user/:repo/:job/:branch*',
+    }
+  }
+
+  static get examples() {
+    return [
+      {
+        title: 'AppVeyor Job',
+        pattern: ':user/:repo/:job',
+        namedParams: {
+          user: 'wpmgprostotema',
+          repo: 'voicetranscoder',
+          job: 'Linux',
+        },
+        staticPreview: renderBuildStatusBadge({ status: 'success' }),
+      },
+      {
+        title: 'AppVeyor Job branch',
+        pattern: ':user/:repo/:job/:branch',
+        namedParams: {
+          user: 'wpmgprostotema',
+          repo: 'voicetranscoder',
+          job: 'Windows',
+          branch: 'master',
+        },
+        staticPreview: renderBuildStatusBadge({ status: 'success' }),
+      },
+    ]
+  }
+
+  transform({ data, jobName }) {
+    if (!data.hasOwnProperty('build')) {
+      // this project exists but no builds have been run on it yet
+      return { status: 'no builds found' }
+    }
+
+    const {
+      build: { jobs },
+    } = data
+    if (!jobs) {
+      throw new NotFound({ prettyMessage: 'no jobs found' })
+    }
+
+    const job = jobs.find(j => j.name === jobName)
+
+    if (!job) {
+      throw new NotFound({ prettyMessage: 'job not found' })
+    }
+
+    return { status: job.status }
+  }
+
+  async handle({ user, repo, job, branch }) {
+    const data = await this.fetch({ user, repo, branch })
+    const { status } = this.transform({ data, jobName: job })
+    return renderBuildStatusBadge({ status })
+  }
+}
diff --git a/services/appveyor/appveyor-job-build.spec.js b/services/appveyor/appveyor-job-build.spec.js
new file mode 100644
index 0000000000..dd37a692be
--- /dev/null
+++ b/services/appveyor/appveyor-job-build.spec.js
@@ -0,0 +1,63 @@
+'use strict'
+
+const { expect } = require('chai')
+const { test, given } = require('sazerac')
+const AppveyorJobBuild = require('./appveyor-job-build.service')
+const { NotFound } = require('..')
+
+describe('AppveyorJobBuild', function() {
+  test(AppveyorJobBuild.prototype.transform, () => {
+    given({ data: {} }).expect({
+      status: 'no builds found',
+    })
+    given({
+      jobName: 'linux',
+      data: {
+        build: {
+          jobs: [
+            {
+              name: 'windows',
+              status: 'failed',
+            },
+            {
+              name: 'linux',
+              status: 'passed',
+            },
+          ],
+        },
+      },
+    }).expect({
+      status: 'passed',
+    })
+  })
+
+  it('throws NotFound when response is missing jobs', function() {
+    expect(() => AppveyorJobBuild.prototype.transform({ data: { build: {} } }))
+      .to.throw(NotFound)
+      .with.property('prettyMessage', 'no jobs found')
+  })
+
+  it('throws NotFound when specified job missing jobs', function() {
+    expect(() =>
+      AppveyorJobBuild.prototype.transform({
+        jobName: 'mac',
+        data: {
+          build: {
+            jobs: [
+              {
+                name: 'linux',
+                status: 'passed',
+              },
+              {
+                name: 'windows',
+                status: 'passed',
+              },
+            ],
+          },
+        },
+      })
+    )
+      .to.throw(NotFound)
+      .with.property('prettyMessage', 'job not found')
+  })
+})


Test patch:
diff --git a/services/appveyor/appveyor-build-redirect.tester.js b/services/appveyor/appveyor-build-redirect.tester.js
new file mode 100644
index 0000000000..625a1f1a16
--- /dev/null
+++ b/services/appveyor/appveyor-build-redirect.tester.js
@@ -0,0 +1,23 @@
+'use strict'
+
+const { ServiceTester } = require('../tester')
+
+const t = (module.exports = new ServiceTester({
+  id: 'AppveyorBuildRedirect',
+  title: 'AppveyorBuildRedirect',
+  pathPrefix: '/appveyor/ci',
+}))
+
+t.create('Appveyor CI')
+  .get('/gruntjs/grunt', {
+    followRedirect: false,
+  })
+  .expectStatus(301)
+  .expectHeader('Location', '/appveyor/build/gruntjs/grunt.svg')
+
+t.create('Appveyor CI (branch)')
+  .get('/gruntjs/grunt/develop', {
+    followRedirect: false,
+  })
+  .expectStatus(301)
+  .expectHeader('Location', '/appveyor/build/gruntjs/grunt/develop.svg')
diff --git a/services/appveyor/appveyor-job-build.tester.js b/services/appveyor/appveyor-job-build.tester.js
new file mode 100644
index 0000000000..81cccb39f2
--- /dev/null
+++ b/services/appveyor/appveyor-job-build.tester.js
@@ -0,0 +1,22 @@
+'use strict'
+
+const { isBuildStatus } = require('../build-status')
+const t = (module.exports = require('../tester').createServiceTester())
+
+t.create('Job CI status')
+  .timeout(10000)
+  .get('/wpmgprostotema/voicetranscoder/Windows.json')
+  .expectBadge({ label: 'build', message: isBuildStatus })
+
+t.create('Job CI status on branch')
+  .timeout(10000)
+  .get('/wpmgprostotema/voicetranscoder/Linux/master.json')
+  .expectBadge({ label: 'build', message: isBuildStatus })
+
+t.create('Job CI status on nonexistent project')
+  .timeout(10000)
+  .get('/somerandomproject/thatdoesntexist/foo.json')
+  .expectBadge({
+    label: 'build',
+    message: 'project not found or access denied',
+  })