Initial implementation of Checks API for Jenkins

This plugin implements the Checks API provided by the Gerrit UI to
show the results of CI systems on the change screen for Jenkins.

The current implementation only works with MultiBranch pipeline
jobs using the Gerrit SCM provided by the gerrit-code-review plugin [1]
for Jenkins. Other job types will be added in future changes.

One or more Jenkins servers with one or more jobs can be configured
for a project. The plugin will regularly fetch jobs created for changes
by the Multibranch pipeline and will extract the build for the
currently viewed patchset.

The plugin is not able to use all features provided by the Checks
API, e.g. providing a result summary, since Jenkins does not
provide this information out of the box. This could however be enabled
in the future by extending Jenkins with a plugin. For now the following
data will be displayed:

- Builds for the selected patchset including previous attempts
- Status of the build
- Result of the build
- Link to the build and its logs
- A result summary stating the result category used by the CI
  (e.g. `Result: UNSTABLE`)

Note, that the plugin will do CORS requests to Jenkins, since it is
running in the client's browser. Thus, CORS has to be enabled in
Jenkins.

[1] https://plugins.jenkins.io/gerrit-code-review/

Change-Id: I7d6825ade3fb0299b230c67cc7d422a660ee4de6
diff --git a/BUILD b/BUILD
new file mode 100644
index 0000000..98e26ba
--- /dev/null
+++ b/BUILD
@@ -0,0 +1,20 @@
+load("//tools/bzl:plugin.bzl", "gerrit_plugin")
+
+package_group(
+    name = "visibility",
+    packages = ["//plugins/checks-jenkins/..."],
+)
+
+package(default_visibility = [":visibility"])
+
+gerrit_plugin(
+    name = "checks-jenkins",
+    srcs = glob(["src/main/java/com/google/gerrit/plugins/checks/jenkins/**/*.java"]),
+    manifest_entries = [
+        "Gerrit-PluginName: checks-jenkins",
+        "Gerrit-Module: com.google.gerrit.plugins.checks.jenkins.ApiModule",
+        "Gerrit-HttpModule: com.google.gerrit.plugins.checks.jenkins.HttpModule",
+    ],
+    resource_jars = ["//plugins/checks-jenkins/web:checks-jenkins"],
+    resources = glob(["src/main/resources/**/*"]),
+)
diff --git a/src/main/java/com/google/gerrit/plugins/checks/jenkins/ApiModule.java b/src/main/java/com/google/gerrit/plugins/checks/jenkins/ApiModule.java
new file mode 100644
index 0000000..3760d89
--- /dev/null
+++ b/src/main/java/com/google/gerrit/plugins/checks/jenkins/ApiModule.java
@@ -0,0 +1,26 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.plugins.checks.jenkins;
+
+import static com.google.gerrit.server.project.ProjectResource.PROJECT_KIND;
+
+import com.google.gerrit.extensions.restapi.RestApiModule;
+
+public class ApiModule extends RestApiModule {
+  @Override
+  protected void configure() {
+    get(PROJECT_KIND, "config").to(GetConfig.class);
+  }
+}
diff --git a/src/main/java/com/google/gerrit/plugins/checks/jenkins/GetConfig.java b/src/main/java/com/google/gerrit/plugins/checks/jenkins/GetConfig.java
new file mode 100644
index 0000000..245b5de
--- /dev/null
+++ b/src/main/java/com/google/gerrit/plugins/checks/jenkins/GetConfig.java
@@ -0,0 +1,67 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.plugins.checks.jenkins;
+
+import com.google.gerrit.extensions.annotations.PluginName;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestReadView;
+import com.google.gerrit.server.config.PluginConfigFactory;
+import com.google.gerrit.server.project.NoSuchProjectException;
+import com.google.gerrit.server.project.ProjectResource;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.util.HashSet;
+import java.util.Set;
+import org.eclipse.jgit.lib.Config;
+
+@Singleton
+class GetConfig implements RestReadView<ProjectResource> {
+  private static final String JENKINS_SECTION = "jenkins";
+  private static final String JENKINS_URL_KEY = "url";
+  private static final String JENKINS_JOB_KEY = "job";
+
+  private final PluginConfigFactory config;
+  private final String pluginName;
+
+  @Inject
+  GetConfig(PluginConfigFactory config, @PluginName String pluginName) {
+    this.config = config;
+    this.pluginName = pluginName;
+  }
+
+  @Override
+  public Response<Set<JenkinsChecksConfig>> apply(ProjectResource project)
+      throws NoSuchProjectException {
+    Set<JenkinsChecksConfig> result = new HashSet<>();
+    Config cfg = config.getProjectPluginConfig(project.getNameKey(), pluginName);
+    for (String instance : cfg.getSubsections(JENKINS_SECTION)) {
+      JenkinsChecksConfig jenkinsCfg = new JenkinsChecksConfig();
+      jenkinsCfg.name = instance;
+      jenkinsCfg.url = cfg.getString(JENKINS_SECTION, instance, JENKINS_URL_KEY);
+      jenkinsCfg.jobs = cfg.getStringList(JENKINS_SECTION, instance, JENKINS_JOB_KEY);
+      result.add(jenkinsCfg);
+    }
+    return Response.ok(result);
+  }
+
+  static class JenkinsChecksConfig {
+    String name;
+    String url;
+    //TODO(Thomas): It would be preferable to not have to configure any jobs, but
+    // to let Jenkins know which Jobs worked on a PatchSet. This will require
+    // additional changes in Jenkins however.
+    String[] jobs;
+  }
+}
diff --git a/src/main/java/com/google/gerrit/plugins/checks/jenkins/HttpModule.java b/src/main/java/com/google/gerrit/plugins/checks/jenkins/HttpModule.java
new file mode 100644
index 0000000..c3372d9
--- /dev/null
+++ b/src/main/java/com/google/gerrit/plugins/checks/jenkins/HttpModule.java
@@ -0,0 +1,28 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.plugins.checks.jenkins;
+
+import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.extensions.webui.JavaScriptPlugin;
+import com.google.gerrit.extensions.webui.WebUiPlugin;
+import com.google.inject.servlet.ServletModule;
+
+public class HttpModule extends ServletModule {
+  @Override
+  protected void configureServlets() {
+    DynamicSet.bind(binder(), WebUiPlugin.class)
+        .toInstance(new JavaScriptPlugin("checks-jenkins.js"));
+  }
+}
diff --git a/src/main/resources/Documentation/about.md b/src/main/resources/Documentation/about.md
new file mode 100644
index 0000000..6d4f006
--- /dev/null
+++ b/src/main/resources/Documentation/about.md
@@ -0,0 +1,23 @@
+Implementation of checks UI for Jenkins CI servers
+
+This plugin registers a `ChecksProvider` with the Gerrit UI that will fetch
+build results for a change from configured Jenkins servers and provide them to
+the checks panel in a change screen.
+
+Limitations
+-----------
+
+Currently, only multibranch-pipeline jobs using the Gerrit SCM-source provided
+by the link:https://plugins.jenkins.io/gerrit-code-review/[gerrit-code-review]-
+plugin are supported.
+
+The Jenkins Remote Access API does not provide all the information that could
+be displayed in Gerrit, e.g. a result summary. Thus, as of now, this plugin
+does not make full use of the checks API. As of right now, it will display the
+following data in the UI:
+
+- Builds for the selected patchset including previous attempts
+- Status of the build
+- Result of the build
+- Link to the build and its logs
+- A result summary stating the result category used by the CI (e.g. `Result: UNSTABLE`)
diff --git a/src/main/resources/Documentation/config.md b/src/main/resources/Documentation/config.md
new file mode 100644
index 0000000..d87e2a0
--- /dev/null
+++ b/src/main/resources/Documentation/config.md
@@ -0,0 +1,18 @@
+Jenkins Checks Configuration
+============================
+
+Jenkins servers can be configured for a project by adding a file called
+`checks-jenkins.config` to the `refs/meta/config` branch of a project.
+
+File `checks-jenkins.config`
+----------------------------
+
+For each Jenkins instance a section with a unique name has to be added.
+
+jenkins.NAME.url
+: Base URL of Jenkins including protocol, e.g. https://gerrit-ci.gerritforge.com
+
+jenkins:NAME:job
+: Name of the multibranch pipeline job using the Gerrit SCM source that should
+  be queried for a build for a patchset, e.g. Gerrit-verifier-pipeline. Can be
+  defined multiple times.
diff --git a/web/.eslintrc.js b/web/.eslintrc.js
new file mode 100644
index 0000000..94ba51b
--- /dev/null
+++ b/web/.eslintrc.js
@@ -0,0 +1,20 @@
+/**
+ * @license
+ * Copyright (C) 2022 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+__plugindir = 'checks-jenkins/web';
+module.exports = {
+  extends: '../../.eslintrc.js',
+};
diff --git a/web/BUILD b/web/BUILD
new file mode 100644
index 0000000..c2b721f
--- /dev/null
+++ b/web/BUILD
@@ -0,0 +1,44 @@
+load("//tools/bzl:plugin.bzl", "gerrit_plugin")
+load("//tools/js:eslint.bzl", "plugin_eslint")
+load("//tools/bzl:js.bzl", "gerrit_js_bundle", "karma_test")
+load("@npm//@bazel/typescript:index.bzl", "ts_config", "ts_project")
+
+package_group(
+    name = "visibility",
+    packages = ["//plugins/checks-jenkins/..."],
+)
+
+package(default_visibility = [":visibility"])
+
+ts_config(
+    name = "tsconfig",
+    src = "tsconfig.json",
+    deps = [
+        "//plugins:tsconfig-plugins-base.json",
+    ],
+)
+
+ts_project(
+    name = "checks-jenkins-ts",
+    srcs = glob(
+        ["**/*.ts"],
+        exclude = ["**/*test*"],
+    ),
+    incremental = True,
+    out_dir = "_bazel_ts_out",
+    tsc = "//tools/node_tools:tsc-bin",
+    tsconfig = ":tsconfig",
+    deps = [
+        "@plugins_npm//@gerritcodereview/typescript-api",
+        "@plugins_npm//lit",
+    ],
+)
+
+gerrit_js_bundle(
+    name = "checks-jenkins",
+    srcs = [":checks-jenkins-ts"],
+    entry_point = "_bazel_ts_out/plugin.js",
+)
+
+# Creates lint_test and lint_bin rules.
+plugin_eslint()
diff --git a/web/fetcher.ts b/web/fetcher.ts
new file mode 100644
index 0000000..0988417
--- /dev/null
+++ b/web/fetcher.ts
@@ -0,0 +1,218 @@
+/**
+ * @license
+ * Copyright (C) 2022 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {
+  Category,
+  ChangeData,
+  CheckResult,
+  CheckRun,
+  ChecksProvider,
+  LinkIcon,
+  ResponseCode,
+  RunStatus,
+} from '@gerritcodereview/typescript-api/checks';
+import {PluginApi} from '@gerritcodereview/typescript-api/plugin';
+
+export declare interface Config {
+  name: string;
+  url: string;
+  jobs: string[];
+}
+
+export declare interface Job {
+  exists: boolean;
+  builds: Build[];
+}
+
+export declare interface Build {
+  number: number;
+  url: string;
+}
+
+export declare interface BuildDetail {
+  number: number;
+  building: boolean;
+  result: string;
+  url: string;
+}
+
+export class ChecksFetcher implements ChecksProvider {
+  private plugin: PluginApi;
+
+  configs: Config[] | null;
+
+  constructor(pluginApi: PluginApi) {
+    this.plugin = pluginApi;
+    this.configs = null;
+  }
+
+  async fetch(changeData: ChangeData) {
+    if (this.configs === null) {
+      await this.fetchConfig(changeData)
+        .then(result => {
+          this.configs = result;
+        })
+        .catch(reason => {
+          throw reason;
+        });
+    }
+    if (this.configs === null) {
+      return {
+        responseCode: ResponseCode.OK,
+        runs: [],
+      };
+    }
+    const checkRuns: CheckRun[] = [];
+    for (const jenkins of this.configs) {
+      for (const jenkinsJob of jenkins.jobs) {
+        // TODO: Requests to Jenkins should be proxied through the Gerrit backend
+        // to avoid CORS requests.
+        const job: Job = await this.fetchJobInfo(
+          this.buildJobApiUrl(jenkins.url, jenkinsJob, changeData)
+        );
+
+        for (const build of job.builds) {
+          checkRuns.push(
+            this.convert(
+              jenkinsJob,
+              changeData,
+              await this.fetchBuildInfo(this.buildBuildApiUrl(build.url))
+            )
+          );
+        }
+      }
+    }
+
+    return {
+      responseCode: ResponseCode.OK,
+      runs: checkRuns,
+    };
+  }
+
+  buildJobApiUrl(
+    jenkinsUrl: string,
+    jenkinsJob: string,
+    changeData: ChangeData
+  ) {
+    let changeShard: string = changeData.changeNumber.toString().slice(-2);
+    if (changeShard.length === 1) {
+      changeShard = '0' + changeShard;
+    }
+    return (
+      jenkinsUrl +
+      '/job/' +
+      jenkinsJob +
+      '/job/' +
+      changeShard +
+      '%252F' +
+      changeData.changeNumber.toString() +
+      '%252F' +
+      changeData.patchsetNumber.toString() +
+      '/api/json?tree=builds[number,url]'
+    );
+  }
+
+  buildBuildApiUrl(baseUrl: string) {
+    return baseUrl + 'api/json?tree=number,result,building,url';
+  }
+
+  fetchConfig(changeData: ChangeData): Promise<Config[]> {
+    const pluginName = encodeURIComponent(this.plugin.getPluginName());
+    return this.plugin
+      .restApi()
+      .get<Config[]>(
+        `/projects/${encodeURIComponent(changeData.repo)}/${pluginName}~config`
+      );
+  }
+
+  async fetchJobInfo(url: string): Promise<Job> {
+    let response: Response;
+    try {
+      response = await fetch(url);
+      if (!response.ok) {
+        throw response.statusText;
+      }
+    } catch (e) {
+      return {
+        exists: false,
+        builds: [],
+      };
+    }
+    const job: Job = await response.json();
+    return job;
+  }
+
+  async fetchBuildInfo(url: string): Promise<BuildDetail> {
+    const response = await fetch(url);
+    if (!response.ok) {
+      throw response.statusText;
+    }
+    const build: BuildDetail = await response.json();
+    return build;
+  }
+
+  convert(
+    checkName: string,
+    changeData: ChangeData,
+    build: BuildDetail
+  ): CheckRun {
+    let status: RunStatus;
+    const results: CheckResult[] = [];
+
+    if (build.result !== null) {
+      status = RunStatus.COMPLETED;
+      let resultCategory: Category;
+      switch (build.result) {
+        case 'SUCCESS':
+          resultCategory = Category.SUCCESS;
+          break;
+        case 'FAILURE':
+          resultCategory = Category.ERROR;
+          break;
+        default:
+          resultCategory = Category.WARNING;
+      }
+
+      const checkResult: CheckResult = {
+        category: resultCategory,
+        summary: `Result: ${build.result}`,
+        links: [
+          {
+            url: build.url + '/console',
+            primary: true,
+            icon: LinkIcon.EXTERNAL,
+          },
+        ],
+      };
+      results.push(checkResult);
+    } else if (build.building) {
+      status = RunStatus.RUNNING;
+    } else {
+      status = RunStatus.RUNNABLE;
+    }
+
+    const run: CheckRun = {
+      change: changeData.changeNumber,
+      patchset: changeData.patchsetNumber,
+      attempt: build.number,
+      checkName,
+      checkLink: build.url,
+      status,
+      results,
+    };
+    return run;
+  }
+}
diff --git a/web/package.json b/web/package.json
new file mode 100644
index 0000000..42090a7
--- /dev/null
+++ b/web/package.json
@@ -0,0 +1,14 @@
+
+{
+    "name": "checks-jenkins",
+    "description": "Checks-Jenkins plugin",
+    "browser": true,
+    "scripts": {
+      "safe_bazelisk": "if which bazelisk >/dev/null; then bazel_bin=bazelisk; else bazel_bin=bazel; fi && $bazel_bin",
+      "eslint": "npm run safe_bazelisk test :lint_test",
+      "eslintfix": "npm run safe_bazelisk run :lint_bin -- -- --fix $(pwd)"
+    },
+    "devDependencies": {},
+    "license": "Apache-2.0",
+    "private": true
+  }
diff --git a/web/plugin.ts b/web/plugin.ts
new file mode 100644
index 0000000..9de1553
--- /dev/null
+++ b/web/plugin.ts
@@ -0,0 +1,26 @@
+/**
+ * @license
+ * Copyright (C) 2022 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import '@gerritcodereview/typescript-api/gerrit';
+import {ChecksFetcher} from './fetcher';
+
+window.Gerrit.install(plugin => {
+  const checksApi = plugin.checks();
+  const fetcher = new ChecksFetcher(plugin);
+  checksApi.register({
+    fetch: data => fetcher.fetch(data),
+  });
+});
diff --git a/web/tsconfig.json b/web/tsconfig.json
new file mode 100644
index 0000000..0ba2503
--- /dev/null
+++ b/web/tsconfig.json
@@ -0,0 +1,10 @@
+{
+  "extends": "../../tsconfig-plugins-base.json",
+  "compilerOptions": {
+    /* outDir for IDE (overridden by Bazel rule arg) */
+    "outDir": "../../../.ts-out/plugins/checks-jenkins/web",
+  },
+  "include": [
+    "**/*"
+  ]
+}