CopyrightReviewApi to update gerrit review threads.

Copyright plugin part 1 of 3: CopyrightReviewApi manages and updates
gerrit code review threads with findings found in new pushed revisions.

CopyrightConfig is a stripped down shell of only what is needed for
CopyrightReviewApi and its tests to build and run. Part 2 will expand
CoprightConfig with listeners and code to parse, validate and load
plugin/scanner configurations.

Part 3 will introduce CopyrightValidator to scan the files in pushed
revisions.

Change-Id: I54f1f69edff19404ecec4f09f21e4ba0051a0b1c
diff --git a/BUILD b/BUILD
index 7585fef..73cc5c5 100644
--- a/BUILD
+++ b/BUILD
@@ -34,7 +34,9 @@
 
 TEST_DEPS = PLUGIN_DEPS + PLUGIN_TEST_DEPS + [
     ":copyright_scanner",
+    "//lib/mockito",
     "@guava//jar",
+    "@mockito//jar",
 ]
 
 junit_tests(
diff --git a/WORKSPACE b/WORKSPACE
index 0bd8981..c8ab81d 100644
--- a/WORKSPACE
+++ b/WORKSPACE
@@ -1,7 +1,7 @@
 workspace(name = "copyright")
 load("//:bazlets.bzl", "load_bazlets")
 load_bazlets(
-    commit = "738ddb525810a50c792736d7115a1bb5289bd3d3",
+    commit = "f3b3eecf061193e8909298bc9c35027df3bf9d44",
     #local_path = "/home/<user>/projects/bazlets",
 )
 
@@ -66,3 +66,30 @@
     artifact = "com.google.j2objc:j2objc-annotations:1.1",
     sha1 = "ed28ded51a8b1c6b112568def5f4b455e6809019",
 )
+
+maven_jar(
+    name = "mockito",
+    artifact = "org.mockito:mockito-core:2.24.0",
+    sha1 = "969a7bcb6f16e076904336ebc7ca171d412cc1f9",
+)
+
+BYTE_BUDDY_VERSION = "1.9.7"
+
+maven_jar(
+    name = "byte-buddy",
+    artifact = "net.bytebuddy:byte-buddy:" + BYTE_BUDDY_VERSION,
+    sha1 = "8fea78fea6449e1738b675cb155ce8422661e237",
+)
+
+maven_jar(
+    name = "byte-buddy-agent",
+    artifact = "net.bytebuddy:byte-buddy-agent:" + BYTE_BUDDY_VERSION,
+    sha1 = "8e7d1b599f4943851ffea125fd9780e572727fc0",
+)
+
+maven_jar(
+    name = "objenesis",
+    artifact = "org.objenesis:objenesis:2.6",
+    sha1 = "639033469776fd37c08358c6b92a4761feb2af4b",
+)
+
diff --git a/lib/mockito/BUILD b/lib/mockito/BUILD
new file mode 100644
index 0000000..89a1400
--- /dev/null
+++ b/lib/mockito/BUILD
@@ -0,0 +1,34 @@
+package(
+    default_testonly = True,
+    default_visibility = ["//visibility:public"],
+)
+
+java_library(
+    name = "mockito",
+    data = ["//lib:LICENSE-Apache2.0"],
+    # Only exposed for plugin tests; core tests should use Easymock
+    exports = ["@mockito//jar"],
+    runtime_deps = [
+        ":byte-buddy",
+        ":byte-buddy-agent",
+        ":objenesis",
+    ],
+)
+
+java_library(
+    name = "byte-buddy",
+    data = ["//lib:LICENSE-Apache2.0"],
+    exports = ["@byte-buddy//jar"],
+)
+
+java_library(
+    name = "byte-buddy-agent",
+    data = ["//lib:LICENSE-Apache2.0"],
+    exports = ["@byte-buddy-agent//jar"],
+)
+
+java_library(
+    name = "objenesis",
+    data = ["//lib:LICENSE-Apache2.0"],
+    exports = ["@objenesis//jar"],
+)
diff --git a/src/main/java/com/googlesource/gerrit/plugins/copyright/CopyrightConfig.java b/src/main/java/com/googlesource/gerrit/plugins/copyright/CopyrightConfig.java
new file mode 100644
index 0000000..c7b0e18
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/copyright/CopyrightConfig.java
@@ -0,0 +1,222 @@
+// Copyright (C) 2019 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.googlesource.gerrit.plugins.copyright;
+
+import static com.google.common.base.Preconditions.checkArgument;
+import static java.util.Objects.requireNonNull;
+
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.base.Joiner;
+import com.google.common.base.Strings;
+import com.google.gerrit.extensions.annotations.PluginName;
+import com.google.gerrit.extensions.api.changes.AddReviewerResult;
+import com.google.gerrit.extensions.api.changes.ReviewResult;
+import com.google.gerrit.extensions.events.GitReferenceUpdatedListener;
+import com.google.gerrit.extensions.events.RevisionCreatedListener;
+import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.metrics.MetricMaker;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.config.AllProjectsName;
+import com.google.gerrit.server.config.PluginConfig;
+import com.google.gerrit.server.config.PluginConfigFactory;
+import com.google.gerrit.server.events.CommitReceivedEvent;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.git.validators.CommitValidationException;
+import com.google.gerrit.server.git.validators.CommitValidationMessage;
+import com.google.gerrit.server.git.validators.ValidationMessage;
+import com.google.gerrit.server.project.ProjectCache;
+import com.google.gerrit.server.project.ProjectConfig;
+import com.google.gerrit.server.project.ProjectState;
+import com.google.inject.AbstractModule;
+import com.google.inject.Singleton;
+import com.googlesource.gerrit.plugins.copyright.lib.CopyrightPatterns;
+import com.googlesource.gerrit.plugins.copyright.lib.CopyrightPatterns.UnknownPatternName;
+import com.googlesource.gerrit.plugins.copyright.lib.CopyrightScanner;
+import com.googlesource.gerrit.plugins.copyright.lib.CopyrightScanner.Match;
+import com.googlesource.gerrit.plugins.copyright.lib.CopyrightScanner.MatchType;
+import com.googlesource.gerrit.plugins.copyright.lib.CopyrightScanner.PartyType;
+import com.googlesource.gerrit.plugins.copyright.lib.IndexedLineReader;
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.concurrent.TimeUnit;
+import java.util.function.Consumer;
+import java.util.regex.Pattern;
+import java.util.regex.PatternSyntaxException;
+import javax.inject.Inject;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Repository;
+
+/** Listener to manage configuration for enforcing review of copyright declarations and licenses. */
+@Singleton
+class CopyrightConfig {
+
+  private static final String DEFAULT_REVIEW_LABEL = "Copyright-Review";
+
+  private final Metrics metrics;
+  private final AllProjectsName allProjectsName;
+  private final String pluginName;
+  private final GitRepositoryManager repoManager;
+  private final ProjectCache projectCache;
+  private final ProjectConfig.Factory projectConfigFactory;
+  private final PluginConfigFactory pluginConfigFactory;
+  private final CopyrightReviewApi reviewApi;
+
+  private ScannerConfig scannerConfig;
+
+  @Singleton
+  private static class Metrics {
+    @Inject
+    Metrics(MetricMaker metricMaker) {}
+  }
+
+  @Inject
+  CopyrightConfig(
+      Metrics metrics,
+      AllProjectsName allProjectsName,
+      @PluginName String pluginName,
+      GitRepositoryManager repoManager,
+      ProjectCache projectCache,
+      ProjectConfig.Factory projectConfigFactory,
+      PluginConfigFactory pluginConfigFactory,
+      CopyrightReviewApi reviewApi) throws IOException {
+    this.metrics = metrics;
+    this.allProjectsName = allProjectsName;
+    this.pluginName = pluginName;
+    this.repoManager = repoManager;
+    this.projectCache = projectCache;
+    this.projectConfigFactory = projectConfigFactory;
+    this.pluginConfigFactory = pluginConfigFactory;
+    this.reviewApi = reviewApi;
+  }
+
+  private CopyrightConfig(MetricMaker metricMaker, CopyrightReviewApi reviewApi) {
+    metrics = new Metrics(metricMaker);
+    allProjectsName = new AllProjectsName("All-Projects");
+    pluginName = "copyright";
+    repoManager = null;
+    projectCache = null;
+    projectConfigFactory = null;
+    pluginConfigFactory = null;
+    this.reviewApi = reviewApi;
+    scannerConfig = this.new ScannerConfig();
+  }
+
+  @VisibleForTesting
+  static CopyrightConfig createTestInstance(MetricMaker metricMaker, CopyrightReviewApi reviewApi) {
+    return new CopyrightConfig(metricMaker, reviewApi);
+  }
+
+  @VisibleForTesting
+  ScannerConfig getScannerConfig() {
+    return scannerConfig;
+  }
+
+  /** Returns true if any pattern in {@code regexes} found in {@code text}. */
+  private static boolean matchesAny(String text, Collection<Pattern> regexes) {
+    requireNonNull(regexes);
+    for (Pattern pattern : regexes) {
+      if (pattern.matcher(text).find()) {
+        return true;
+      }
+    }
+    return false;
+  }
+
+  /** Configuration state for {@link CopyrightValidator}. */
+  class ScannerConfig {
+    CopyrightScanner scanner;
+    final ArrayList<CommitValidationMessage> messages;
+    final LinkedHashSet<Pattern> alwaysReviewPath;
+    final LinkedHashSet<Pattern> matchProjects;
+    final LinkedHashSet<Pattern> excludeProjects;
+    final LinkedHashSet<Pattern> thirdPartyAllowedProjects;
+    final LinkedHashSet<String> reviewers;
+    final LinkedHashSet<String> ccs;
+    String reviewLabel;
+    boolean defaultEnable;
+    int fromAccountId;
+
+    ScannerConfig() {
+      this.messages = new ArrayList<>();
+      this.alwaysReviewPath = new LinkedHashSet<>();
+      this.matchProjects = new LinkedHashSet<>();
+      this.excludeProjects = new LinkedHashSet<>();
+      this.thirdPartyAllowedProjects = new LinkedHashSet<>();
+      this.reviewers = new LinkedHashSet<>();
+      this.ccs = new LinkedHashSet<>();
+      this.reviewLabel = DEFAULT_REVIEW_LABEL;
+      this.defaultEnable = false;
+      this.fromAccountId = 0;
+    }
+
+    @Override
+    public boolean equals(Object other) {
+      if (this == other) {
+        return true;
+      }
+      if (other == null) {
+        return false;
+      }
+      if (other instanceof ScannerConfig) {
+        ScannerConfig otherConfig = (ScannerConfig) other;
+        return defaultEnable == otherConfig.defaultEnable
+            && messages.equals(otherConfig.messages)
+            && alwaysReviewPath.equals(otherConfig.alwaysReviewPath)
+            && matchProjects.equals(otherConfig.matchProjects)
+            && excludeProjects.equals(otherConfig.excludeProjects)
+            && thirdPartyAllowedProjects.equals(otherConfig.thirdPartyAllowedProjects)
+            && reviewers.equals(otherConfig.reviewers)
+            && ccs.equals(otherConfig.ccs)
+            && Objects.equals(reviewLabel, otherConfig.reviewLabel)
+            && Objects.equals(scanner, otherConfig.scanner);
+      }
+      return false;
+    }
+
+    @Override
+    public int hashCode() {
+      return Objects.hash(
+          defaultEnable,
+          messages,
+          alwaysReviewPath,
+          matchProjects,
+          excludeProjects,
+          thirdPartyAllowedProjects,
+          reviewers,
+          ccs,
+          reviewLabel,
+          scanner);
+    }
+
+    /** Returns true if {@code project} repository allows third-party code. */
+    boolean isThirdPartyAllowed(String project) {
+      return matchesAny(project, thirdPartyAllowedProjects);
+    }
+  }
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/copyright/CopyrightReviewApi.java b/src/main/java/com/googlesource/gerrit/plugins/copyright/CopyrightReviewApi.java
new file mode 100644
index 0000000..89c9c78
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/copyright/CopyrightReviewApi.java
@@ -0,0 +1,567 @@
+// Copyright (C) 2019 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.googlesource.gerrit.plugins.copyright;
+
+import static com.googlesource.gerrit.plugins.copyright.lib.CopyrightScanner.MatchType.AUTHOR_OWNER;
+import static com.googlesource.gerrit.plugins.copyright.lib.CopyrightScanner.MatchType.LICENSE;
+import static com.googlesource.gerrit.plugins.copyright.lib.CopyrightScanner.PartyType.FIRST_PARTY;
+import static com.googlesource.gerrit.plugins.copyright.lib.CopyrightScanner.PartyType.THIRD_PARTY;
+import static com.googlesource.gerrit.plugins.copyright.lib.CopyrightScanner.PartyType.FORBIDDEN;
+import static com.googlesource.gerrit.plugins.copyright.lib.CopyrightScanner.PartyType.UNKNOWN;
+
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.base.Preconditions;
+import com.google.common.base.Throwables;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.api.changes.ReviewInput.CommentInput;
+import com.google.gerrit.extensions.api.changes.ReviewResult;
+import com.google.gerrit.extensions.client.Comment;
+import com.google.gerrit.extensions.client.ReviewerState;
+import com.google.gerrit.extensions.common.CommentInfo;
+import com.google.gerrit.extensions.events.RevisionCreatedListener;
+import com.google.gerrit.extensions.restapi.IdString;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.metrics.Counter0;
+import com.google.gerrit.metrics.Counter1;
+import com.google.gerrit.metrics.Description;
+import com.google.gerrit.metrics.Description.Units;
+import com.google.gerrit.metrics.Field;
+import com.google.gerrit.metrics.MetricMaker;
+import com.google.gerrit.metrics.Timer0;
+import com.google.gerrit.metrics.Timer1;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.PatchSetUtil;
+import com.google.gerrit.server.PluginUser;
+import com.google.gerrit.server.change.ChangeResource;
+import com.google.gerrit.server.change.RevisionResource;
+import com.google.gerrit.server.git.validators.CommitValidationMessage;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.restapi.change.ListChangeComments;
+import com.google.gerrit.server.restapi.change.PostReview;
+import com.google.inject.Singleton;
+import com.google.inject.Provider;
+import com.googlesource.gerrit.plugins.copyright.lib.CopyrightScanner.Match;
+import com.googlesource.gerrit.plugins.copyright.lib.CopyrightScanner.MatchType;
+import com.googlesource.gerrit.plugins.copyright.lib.CopyrightScanner.PartyType;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.concurrent.TimeUnit;
+import javax.inject.Inject;
+
+/** Utility to report revision findings on the review thread. */
+@Singleton
+public class CopyrightReviewApi {
+  static final ImmutableList<Match> ALWAYS_REVIEW = ImmutableList.of();
+
+  private final Metrics metrics;
+  private final Provider<PluginUser> pluginUserProvider;
+  private final Provider<CurrentUser> userProvider;
+  private final IdentifiedUser.GenericFactory identifiedUserFactory;
+  private final ChangeNotes.Factory changeNotesFactory;
+  private final ChangeResource.Factory changeResourceFactory;
+  private final PatchSetUtil psUtil;
+  private final PostReview postReview;
+  private final ListChangeComments listChangeComments;
+
+  @Singleton
+  static class Metrics {
+    final Counter0 reviewCount;
+    final Counter0 commentCount;
+    final Timer0 reviewTimer;
+    final Counter1 reviewCountByProject;
+    final Counter1 commentCountByProject;
+    final Timer1 reviewTimerByProject;
+
+    @Inject
+    Metrics(MetricMaker metricMaker) {
+      Field<String> project = Field.ofString("project", "project name");
+      reviewCount =
+          metricMaker.newCounter(
+              "plugin/copyright/review_count",
+              new Description("Total number of posted reviews").setRate().setUnit("reviews"));
+      commentCount =
+          metricMaker.newCounter(
+              "plugin/copyright/comment_count",
+              new Description("Total number of posted review comments")
+                  .setRate()
+                  .setUnit("comments"));
+      reviewTimer =
+          metricMaker.newTimer(
+              "plugin/copyright/review_latency",
+              new Description("Time spent posting reviews to revisions")
+                  .setCumulative()
+                  .setUnit(Units.MICROSECONDS));
+      reviewCountByProject =
+          metricMaker.newCounter(
+              "plugin/copyright/review_count_by_project",
+              new Description("Total number of posted reviews").setRate().setUnit("reviews"),
+              project);
+      commentCountByProject =
+          metricMaker.newCounter(
+              "plugin/copyright/comment_count_by_project",
+              new Description("Total number of posted review comments")
+                  .setRate()
+                  .setUnit("comments"),
+              project);
+      reviewTimerByProject =
+          metricMaker.newTimer(
+              "plugin/copyright/review_latency_by_project",
+              new Description("Time spent posting reviews to revisions")
+                  .setCumulative()
+                  .setUnit(Units.MICROSECONDS),
+              project);
+    }
+  }
+
+  @Inject
+  CopyrightReviewApi(
+      Metrics metrics,
+      Provider<PluginUser> pluginUserProvider,
+      Provider<CurrentUser> userProvider,
+      IdentifiedUser.GenericFactory identifiedUserFactory,
+      ChangeNotes.Factory changeNotesFactory,
+      ChangeResource.Factory changeResourceFactory,
+      PatchSetUtil psUtil,
+      PostReview postReview,
+      ListChangeComments listChangeComments) {
+    this.metrics = metrics;
+    this.pluginUserProvider = pluginUserProvider;
+    this.userProvider = userProvider;
+    this.identifiedUserFactory = identifiedUserFactory;
+    this.changeNotesFactory = changeNotesFactory;
+    this.changeResourceFactory = changeResourceFactory;
+    this.psUtil = psUtil;
+    this.postReview = postReview;
+    this.listChangeComments = listChangeComments;
+  }
+
+  /**
+   * Reports validation findings for a proposed new plugin configuration to the review thread for
+   * the newly pushed revision.
+   *
+   * @param pluginName as installed in gerrit
+   * @param project identifies the All-Projects config for recording metrics
+   * @param path identifies the file to attach messages to (@code gerrit.config or project.config}
+   * @param oldConfig the prior state of the plugin configuration
+   * @param newConfig the new state of the plugin configuration
+   * @param event describes the pushed revision with the new configuration
+   * @throws RestApiException if an error occurs updating the review thread
+   */
+  ReviewResult reportConfigMessages(
+      String pluginName,
+      String project,
+      String path,
+      CopyrightConfig.ScannerConfig oldConfig,
+      CopyrightConfig.ScannerConfig newConfig,
+      RevisionCreatedListener.Event event) throws RestApiException {
+    Preconditions.checkNotNull(newConfig);
+    Preconditions.checkNotNull(newConfig.messages);
+    Preconditions.checkArgument(!newConfig.messages.isEmpty());
+
+    long startNanos = System.nanoTime();
+    metrics.reviewCount.increment();
+    metrics.reviewCountByProject.increment(project);
+
+    try {
+      int fromAccountId =
+          oldConfig != null && oldConfig.fromAccountId > 0
+              ? oldConfig.fromAccountId
+                  : newConfig.fromAccountId;
+      ChangeResource change = getChange(event, fromAccountId);
+      StringBuilder message = new StringBuilder();
+      message.append(pluginName);
+      message.append(" plugin issues parsing new configuration");
+      ReviewInput ri = new ReviewInput()
+          .message(message.toString());
+
+      Map<String, List<CommentInfo>> priorComments = getComments(change);
+      if (priorComments == null) {
+        priorComments = ImmutableMap.of();
+      }
+
+      int numComments = 0;
+      ImmutableMap.Builder<String, List<CommentInput>> comments = ImmutableMap.builder();
+      for (CommitValidationMessage m : newConfig.messages) {
+        message.setLength(0);
+        message.append(m.getType().toString());
+        message.append(" ");
+        message.append(m.getMessage());
+        CommentInput ci = new CommentInput();
+        ci.line = 0;
+        ci.unresolved = true;
+        ci.message = message.toString();
+        if (containsComment(priorComments.get(path), ci)) {
+          continue;
+        }
+        comments.put(path, ImmutableList.of(ci));
+        numComments++;
+      }
+      if (numComments > 0) {
+        ri.comments = comments.build();
+      }
+      metrics.commentCount.incrementBy((long) numComments);
+      metrics.commentCountByProject.incrementBy(project, (long) numComments);
+      return review(change, ri);
+    } finally {
+      long elapsedMicros = (System.nanoTime() - startNanos) / 1000;
+      metrics.reviewTimer.record(elapsedMicros, TimeUnit.MICROSECONDS);
+      metrics.reviewTimerByProject.record(project, elapsedMicros, TimeUnit.MICROSECONDS);
+    }
+  }
+
+  /**
+   * Reports {@link CopyrightValidator} findings from a scanned revision on its review thread.
+   *
+   * @param project identifies the project where change pushed for recording metrics
+   * @param scannerConfig the state of the plugin configuration and scanner
+   * @param event describes the pushed revision scanned by the plugin
+   * @param findings maps each scanned file to the copyright matches found by the scanner
+   * @throws RestApiException if an error occurs updating the review thread
+   */
+  ReviewResult reportScanFindings(
+      String project,
+      CopyrightConfig.ScannerConfig scannerConfig,
+      RevisionCreatedListener.Event event,
+      Map<String, ImmutableList<Match>> findings) throws RestApiException {
+    long startNanos = System.nanoTime();
+    metrics.reviewCount.increment();
+    metrics.reviewCountByProject.increment(project);
+
+    try {
+      boolean tpAllowed = scannerConfig.isThirdPartyAllowed(project);
+      boolean reviewRequired = false;
+      for (Map.Entry<String, ImmutableList<Match>> entry : findings.entrySet()) {
+        if (entry.getValue() == ALWAYS_REVIEW) {
+          reviewRequired = true;
+          break;
+        }
+        PartyType pt = partyType(entry.getValue());
+        if (pt.compareTo(THIRD_PARTY) > 0) {
+          reviewRequired = true;
+          break;
+        }
+        if (pt == THIRD_PARTY && !tpAllowed) {
+          reviewRequired = true;
+          break;
+        }
+      }
+      ChangeResource change = getChange(event, scannerConfig.fromAccountId);
+      ReviewInput ri = new ReviewInput()
+          .message("Copyright scan")
+          .label(scannerConfig.reviewLabel, reviewRequired ? -1 : +2);
+
+      if (reviewRequired) {
+        ri = addReviewers(ri, scannerConfig.ccs, ReviewerState.CC);
+        ri = addReviewers(ri, scannerConfig.reviewers, ReviewerState.REVIEWER);
+      }
+      ImmutableMap.Builder<String, List<CommentInput>> comments = ImmutableMap.builder();
+      if (reviewRequired) {
+        ri = ri.message("This change requires copyright review.");
+      } else {
+        ri = ri.message("This change appears to comply with copyright requirements.");
+      }
+      Map<String, List<CommentInfo>> priorComments = getComments(change);
+      if (priorComments == null) {
+        priorComments = ImmutableMap.of();
+      }
+
+      int numCommentsAdded = 0;
+      for (Map.Entry<String, ImmutableList<Match>> entry : findings.entrySet()) {
+        ImmutableList<CommentInput> newComments = null;
+        if (entry.getValue() == ALWAYS_REVIEW) {
+          CommentInput ci = new CommentInput();
+          ci.line = 0;
+          ci.unresolved = true;
+          ci.message = entry.getKey() + " always requires copyright review";
+          if (containsComment(priorComments.get(entry.getKey()), ci)) {
+            continue;
+          }
+          newComments = ImmutableList.of(ci);
+        } else {
+          PartyType pt = partyType(entry.getValue());
+          newComments = reviewComments(project, pt, tpAllowed, entry.getValue());
+          List<CommentInfo> prior = priorComments.get(entry.getKey());
+          newComments = ImmutableList.copyOf(
+              newComments.stream().filter(ci -> !containsComment(prior, ci))
+                  .toArray(i -> new CommentInput[i]));
+          if (newComments.isEmpty()) {
+            continue;
+          }
+        }
+        numCommentsAdded += newComments.size();
+        comments.put(entry.getKey(), newComments);
+      }
+
+      if (numCommentsAdded > 0) {
+        ri.comments = comments.build();
+      }
+      metrics.commentCount.incrementBy((long) numCommentsAdded);
+      metrics.commentCountByProject.incrementBy(project, (long) numCommentsAdded);
+      return review(change, ri);
+    } finally {
+      long elapsedMicros = (System.nanoTime() - startNanos) / 1000;
+      metrics.reviewTimer.record(elapsedMicros, TimeUnit.MICROSECONDS);
+      metrics.reviewTimerByProject.record(project, elapsedMicros, TimeUnit.MICROSECONDS);
+    }
+  }
+
+  /**
+   * Returns the {@link com.google.gerrit.server.CurrentUser} seeming to send the review comments.
+   *
+   * Impersonates {@code fromAccountId} if configured by {@code fromAccountId =} in plugin
+   * configuration -- falling back to the identity of the user pushing the revision.
+   */
+  CurrentUser getSendingUser(int fromAccountId) {
+      PluginUser pluginUser = pluginUserProvider.get();
+      return fromAccountId <= 0 ? userProvider.get() :
+          identifiedUserFactory.runAs(null, new Account.Id(fromAccountId), pluginUser);
+  }
+
+  /**
+   * Constructs a {@link com.google.gerrit.server.change.ChangeResource} from the notes log for
+   * the change onto which a new revision was pushed.
+   *
+   * @param event describes the newly pushed revision
+   * @param fromAccountId identifies the configured user to impersonate when sending review comments
+   * @throws RestApiException if an error occurs looking up the notes log for the change
+   */
+  private ChangeResource getChange(RevisionCreatedListener.Event event, int fromAccountId)
+      throws RestApiException {
+    try {
+      CurrentUser fromUser = getSendingUser(fromAccountId);
+      ChangeNotes notes =
+          changeNotesFactory.createChecked(new Change.Id(event.getChange()._number));
+      return changeResourceFactory.create(notes, fromUser);
+    } catch (Exception e) {
+      Throwables.throwIfUnchecked(e);
+      throw e instanceof RestApiException
+          ? (RestApiException) e : new RestApiException("Cannot load change", e);
+    }
+  }
+
+  /**
+   * Returns a modified {@link com.google.gerrit.extensions.api.changes.ReviewInput} after adding
+   * {@code reviewers} as {@code type} CC or REVIEWER.
+   */
+  @VisibleForTesting
+  ReviewInput addReviewers(ReviewInput ri, Iterable<String> reviewers, ReviewerState type) {
+    for (String reviewer : reviewers) {
+      ri = ri.reviewer(reviewer, type, true);
+    }
+    return ri;
+  }
+
+  /**
+   * Retrieves all of the prior review comments already attached to {@code change}.
+   *
+   * @throws RestApiException if an error occurs retrieving the comments
+   */
+  private Map<String, List<CommentInfo>> getComments(ChangeResource change)
+      throws RestApiException {
+    try {
+      return listChangeComments.apply(change);
+    } catch (Exception e) {
+      Throwables.throwIfUnchecked(e);
+      throw e instanceof RestApiException
+          ? (RestApiException) e : new RestApiException("Cannot list comments", e);
+    }
+  }
+
+  /**
+   * Adds the code review described by {@code ri} to the review thread of {@code change}.
+   *
+   * @throws RestApiException if an error occurs updating the review thread
+   */
+  private ReviewResult review(ChangeResource change, ReviewInput ri) throws RestApiException {
+    try {
+      PatchSet ps = psUtil.current(change.getNotes());
+      if (ps == null) {
+        throw new ResourceNotFoundException(IdString.fromDecoded("current"));
+      }
+      RevisionResource revision = RevisionResource.createNonCacheable(change, ps);
+      return postReview.apply(revision, ri).value();
+    } catch (Exception e) {
+      Throwables.throwIfUnchecked(e);
+      throw e instanceof RestApiException
+          ? (RestApiException) e : new RestApiException("Cannot post review", e);
+    }
+  }
+
+  /** Returns true if {@code priorComments} already includes a comment identical to {@code ci}. */
+  @VisibleForTesting
+  boolean containsComment(Iterable<? extends Comment> priorComments, CommentInput ci) {
+    if (priorComments == null) {
+      return false;
+    }
+    for (Comment prior : priorComments) {
+      if (Objects.equals(prior.line, ci.line)
+          && Objects.equals(prior.range, ci.range)
+          && Objects.equals(prior.message, ci.message)) {
+        return true;
+      }
+    }
+    return false;
+  }
+
+  /**
+   * Puts the pieces together from a scanner finding to construct a coherent human-reable message.
+   *
+   * @param project describes the project or repository where the revision was pushed
+   * @param overallPt identifies the calculated
+   *     {@link com.googlesource.gerrit.plugins.copyright.lib.CopyrightScanner.PartyType} for all of
+   *     the findings in the file. e.g. 1p license + 3p owner == 1p, no license + 3p owner == 3p
+   * @param pt identifies the {@code PartyType} of the current finding
+   * @param mt identifies the
+   *     {@link com.googlesource.gerrit.plugins.copyright.lib.CopyrightScanner.MatchType} of the
+   *     current finding. i.e. AUTHOR_OWNER or LICENSE
+   * @param tpAllowed is true if {@code project} allows third-party code
+   * @param text of the message for the finding
+   */
+  private String buildMessage(
+      String project,
+      PartyType overallPt,
+      PartyType pt,
+      MatchType mt,
+      boolean tpAllowed,
+      StringBuilder text) {
+    StringBuilder message = new StringBuilder();
+    switch (pt) {
+      case FIRST_PARTY:
+        message.append("First-party ");
+        break;
+      case THIRD_PARTY:
+        message.append("Third-party ");
+        break;
+      case FORBIDDEN:
+        message.append("Disapproved ");
+        break;
+      default:
+        message.append("Unrecognized ");
+        break;
+    }
+    message.append(mt == AUTHOR_OWNER ? "author or owner " : "license ");
+    if (pt == THIRD_PARTY && overallPt != FIRST_PARTY) {
+      if (!tpAllowed) {
+        message.append("dis");
+      }
+      message.append("allowed in repository ");
+      message.append(project);
+    }
+    message.append(":\n\n");
+    message.append(text);
+    return message.toString();
+  }
+
+  /**
+   * Converts the scanner findings in {@code matches} into human-readable review comments.
+   *
+   * @param project the project or repository to which the revision was pushed
+   * @param pt the calculated overall
+   *     {@link com.googlesource.gerrit.plugins.copyright.lib.CopyrightScanner.PartyType} for the
+   *     findings e.g. 1p license + 3p owner = 1p, no license + 3p owner = 3p
+   * @param tpAllowed is true if {@code project} allows third-party code
+   * @param matches describes the location and types of matches found in a file
+   * @return a list of {@link com.google.gerrit.extensions.api.changes.ReviewInput.CommentInput} to
+   *     add to the {@link com.google.gerrit.extensions.api.changes.ReviewInput} to add to the
+   *     review thread for the current patch set.
+   */
+  @VisibleForTesting
+  ImmutableList<CommentInput> reviewComments(
+      String project, PartyType pt, boolean tpAllowed, Iterable<Match> matches) {
+    ImmutableList.Builder<CommentInput> builder = ImmutableList.builder();
+    CommentInput ci = null;
+    PartyType previousPt = UNKNOWN;
+    MatchType previousMt = LICENSE;
+    StringBuilder text = new StringBuilder();
+    for (Match m : matches) {
+      if (ci == null
+          || previousPt != m.partyType
+          || m.endLine > ci.range.startLine + 60
+          || m.startLine > ci.range.endLine + 40) {
+        if (ci != null) {
+          ci.message = buildMessage(project, pt, previousPt, previousMt, tpAllowed, text);
+          builder.add(ci);
+        }
+        ci = new CommentInput();
+        boolean allowed = m.partyType == FIRST_PARTY
+            || (m.partyType == THIRD_PARTY && tpAllowed)
+            || (m.partyType == THIRD_PARTY && m.matchType == AUTHOR_OWNER && pt == FIRST_PARTY);
+        ci.unresolved = !allowed;
+        ci.range = new Comment.Range();
+        ci.line = m.endLine;
+        ci.range.startLine = m.startLine;
+        ci.range.endLine = m.endLine;
+        ci.range.startCharacter = 0;
+        ci.range.endCharacter = 0;
+        previousPt = m.partyType;
+        previousMt = m.matchType;
+        text.setLength(0);
+        text.append(m.text);
+        continue;
+      }
+      text.append("...");
+      text.append(m.text);
+      // an author or owner inside a license declaration is a normal part of a license declaration
+      if (m.matchType == LICENSE) {
+        previousMt = LICENSE;
+      }
+      if (m.startLine < ci.range.startLine) {
+        ci.range.startLine = m.startLine;
+      }
+      if (m.endLine > ci.range.endLine) {
+        ci.range.endLine = m.endLine;
+        ci.line = m.endLine;
+      }
+    }
+    if (ci != null) {
+      ci.message = buildMessage(project, pt, previousPt, previousMt, tpAllowed, text);
+      builder.add(ci);
+    }
+    return builder.build();
+  }
+
+  /**
+   * Calculates and returns the overall
+   * {@link com.googlesource.gerrit.plugins.copyright.lib.CopyrightScanner.PartyType} for the
+   * copyright scanner findings in {@code matches}.
+   */
+  @VisibleForTesting
+  PartyType partyType(Iterable<Match> matches) {
+    PartyType pt = PartyType.FIRST_PARTY;
+    boolean hasThirdPartyOwner = false;
+    boolean hasFirstPartyLicense = false;
+    for (Match match : matches) {
+      if (match.partyType == PartyType.THIRD_PARTY && match.matchType == MatchType.AUTHOR_OWNER) {
+        hasThirdPartyOwner = true;
+      } else if (match.partyType == PartyType.FIRST_PARTY && match.matchType == MatchType.LICENSE) {
+        hasFirstPartyLicense = true;
+      } else if (match.partyType.compareTo(pt) > 0) {
+        pt = match.partyType;
+      }
+    }
+    if (pt == PartyType.FIRST_PARTY && hasThirdPartyOwner && !hasFirstPartyLicense) {
+      return PartyType.THIRD_PARTY;
+    }
+    return pt;
+  }
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/copyright/lib/CopyrightScanner.java b/src/main/java/com/googlesource/gerrit/plugins/copyright/lib/CopyrightScanner.java
index e5e2888..ddc5f78 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/copyright/lib/CopyrightScanner.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/copyright/lib/CopyrightScanner.java
@@ -301,6 +301,26 @@
     this.copyright = buildPattern();
   }
 
+  @Override
+  public boolean equals(Object other) {
+    if (this == other) {
+      return true;
+    }
+    if (other == null) {
+      return false;
+    }
+    if (other instanceof CopyrightScanner) {
+      CopyrightScanner otherScanner = (CopyrightScanner) other;
+      return copyright.equals(otherScanner.copyright);
+    }
+    return false;
+  }
+
+  @Override
+  public int hashCode() {
+    return copyright.hashCode();
+  }
+
   /**
    * Scans `source` for copyright notices returning found license/author/owner information.
    *
@@ -979,8 +999,8 @@
   public enum PartyType {
     FIRST_PARTY,
     THIRD_PARTY,
-    FORBIDDEN,
     UNKNOWN,
+    FORBIDDEN,
   }
 
   /** Identifies whether text matched by author/owner pattern or by license pattern. */
@@ -1014,11 +1034,11 @@
     /** The character offset into the file where the match ends. */
     public int end;
 
-    Match(PartyType partyType, String text, int startLine, int endLine, int start, int end) {
+    public Match(PartyType partyType, String text, int startLine, int endLine, int start, int end) {
       this(partyType, MatchType.AUTHOR_OWNER, text, startLine, endLine, start, end);
     }
 
-    Match(
+    public Match(
         PartyType partyType,
         MatchType matchType,
         String text,
diff --git a/src/test/java/com/googlesource/gerrit/plugins/copyright/CopyrightReviewApiTest.java b/src/test/java/com/googlesource/gerrit/plugins/copyright/CopyrightReviewApiTest.java
new file mode 100644
index 0000000..4c0b4b6
--- /dev/null
+++ b/src/test/java/com/googlesource/gerrit/plugins/copyright/CopyrightReviewApiTest.java
@@ -0,0 +1,449 @@
+// Copyright (C) 2019 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.googlesource.gerrit.plugins.copyright;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.googlesource.gerrit.plugins.copyright.lib.CopyrightScanner.MatchType.AUTHOR_OWNER;
+import static com.googlesource.gerrit.plugins.copyright.lib.CopyrightScanner.MatchType.LICENSE;
+import static com.googlesource.gerrit.plugins.copyright.lib.CopyrightScanner.PartyType.FIRST_PARTY;
+import static com.googlesource.gerrit.plugins.copyright.lib.CopyrightScanner.PartyType.THIRD_PARTY;
+import static com.googlesource.gerrit.plugins.copyright.lib.CopyrightScanner.PartyType.FORBIDDEN;
+import static com.googlesource.gerrit.plugins.copyright.lib.CopyrightScanner.PartyType.UNKNOWN;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.truth.Correspondence;
+import com.google.gerrit.extensions.api.changes.AddReviewerInput;
+import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.api.changes.ReviewInput.CommentInput;
+import com.google.gerrit.extensions.client.ReviewerState;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.PluginUser;
+import com.google.gerrit.server.account.GroupMembership;
+import com.googlesource.gerrit.plugins.copyright.lib.CopyrightScanner.Match;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Mock;
+import org.mockito.junit.MockitoJUnitRunner;
+
+@RunWith(MockitoJUnitRunner.class)
+public class CopyrightReviewApiTest {
+  private static final PluginUser pluginUser = new FakePluginUser();
+  private static final CurrentUser currentUser = new FakeCurrentUser();
+
+  @Mock private IdentifiedUser.GenericFactory identifiedUserFactory;
+
+  private CopyrightReviewApi reviewApi;
+
+  @Before
+  public void setUp() throws Exception {
+    reviewApi = new CopyrightReviewApi(
+        null,
+        () -> pluginUser,
+        () -> currentUser,
+        identifiedUserFactory,
+        null,
+        null,
+        null,
+        null,
+        null);
+  }
+
+  @Test
+  public void testGetSendingUser_fromAccountIdConfigured() throws Exception {
+    CurrentUser from = reviewApi.getSendingUser(808);
+    verify(identifiedUserFactory, times(1))
+        .runAs(eq(null), any(Account.Id.class), any(PluginUser.class));
+  }
+
+  @Test
+  public void testGetSendingUser_noFromAccountIdConfigured() throws Exception {
+    CurrentUser from = reviewApi.getSendingUser(0);
+    verify(identifiedUserFactory, never())
+        .runAs(eq(null), any(Account.Id.class), any(PluginUser.class));
+    assertThat(from).isSameAs(currentUser);
+  }
+
+  @Test
+  public void testAddReviewers_addNone() throws Exception {
+    ReviewInput ri = new ReviewInput();
+    ri = reviewApi.addReviewers(ri, ImmutableList.of(), ReviewerState.CC);
+    assertThat(ri.reviewers).isNull();
+    ri = reviewApi.addReviewers(ri, ImmutableList.of(), ReviewerState.REVIEWER);
+    assertThat(ri.reviewers).isNull();
+  }
+
+  @Test
+  public void testAddReviewers_addCC() throws Exception {
+    ReviewInput ri = new ReviewInput();
+    ri = reviewApi.addReviewers(ri, ImmutableList.of("someone"), ReviewerState.CC);
+    assertThat(ri.reviewers)
+        .comparingElementsUsing(addressedTo())
+        .containsExactly("CC:someone");
+  }
+
+  @Test
+  public void testAddReviewers_addReviewer() throws Exception {
+    ReviewInput ri = new ReviewInput();
+    ri = reviewApi.addReviewers(ri, ImmutableList.of("someone"), ReviewerState.REVIEWER);
+    assertThat(ri.reviewers)
+        .comparingElementsUsing(addressedTo())
+        .containsExactly("REVIEWER:someone");
+  }
+
+  @Test
+  public void testAddReviewers_addMultiple() throws Exception {
+    ReviewInput ri = new ReviewInput();
+    ri = reviewApi.addReviewers(
+        ri, ImmutableList.of("someone", "someone else"), ReviewerState.REVIEWER);
+    ri = reviewApi.addReviewers(ri, ImmutableList.of("another", "and another"), ReviewerState.CC);
+    assertThat(ri.reviewers)
+        .comparingElementsUsing(addressedTo())
+        .containsExactly(
+            "REVIEWER:someone", "REVIEWER:someone else", "CC:another", "CC:and another");
+  }
+
+  @Test
+  public void testContainsComment_empty() throws Exception {
+    assertThat(reviewApi.containsComment(ImmutableList.of(), CI("text", 1, 2))).isFalse();
+  }
+
+  @Test
+  public void testContainsComment_identity() throws Exception {
+    CommentInput ci = CI("test text", 2, 3);
+    assertThat(reviewApi.containsComment(ImmutableList.of(ci), ci)).isTrue();
+  }
+
+  @Test
+  public void testContainsComment_sameValues() throws Exception {
+    CommentInput ci = CI("test text", 2, 3);
+    CommentInput twin = CI("test text", 2, 3);
+    assertThat(reviewApi.containsComment(ImmutableList.of(ci), twin)).isTrue();
+  }
+
+  @Test
+  public void testContainsComment_singleDifferentRangeStart() throws Exception {
+    CommentInput ci = CI("a comment", 4, 5);
+    CommentInput otherRange = CI("a comment", 3, 5);
+    assertThat(reviewApi.containsComment(ImmutableList.of(ci), otherRange)).isFalse();
+  }
+
+  @Test
+  public void testContainsComment_singleDifferentRangeEnd() throws Exception {
+    CommentInput ci = CI("a comment", 6, 7);
+    CommentInput otherRange = CI("a comment", 6, 8);
+    assertThat(reviewApi.containsComment(ImmutableList.of(ci), otherRange)).isFalse();
+  }
+
+  @Test
+  public void testContainsComment_singleDifferentText() throws Exception {
+    CommentInput ci = CI("a comment", 9, 9);
+    CommentInput otherText = CI("another comment", 9, 9);
+    assertThat(reviewApi.containsComment(ImmutableList.of(ci), otherText)).isFalse();
+  }
+
+  @Test
+  public void testContainsComment_multipleDoContain() throws Exception {
+    ImmutableList<CommentInput> comments = ImmutableList.of(
+        CI("one", 1, 2),
+        CI("two", 806, 808),
+        CI("three", 3, 14));
+    assertThat(reviewApi.containsComment(comments, CI("three", 3, 14))).isTrue();
+    assertThat(reviewApi.containsComment(comments, CI("two", 806, 808))).isTrue();
+    assertThat(reviewApi.containsComment(comments, CI("one", 1, 2))).isTrue();
+  }
+
+  @Test
+  public void testContainsComment_multipleDoNotContain() throws Exception {
+    ImmutableList<CommentInput> comments = ImmutableList.of(
+        CI("one", 1, 2),
+        CI("two", 806, 808),
+        CI("three", 3, 14));
+    assertThat(reviewApi.containsComment(comments, CI("four", 806, 808))).isFalse();
+  }
+
+  @Test
+  public void testReviewComments_firstParty() throws Exception {
+    assertThat(
+        reviewApi.reviewComments(
+            "project",
+            FIRST_PARTY, // 1p license with 3p author is 1p license
+            false,
+            ImmutableList.of(lic1p(2), auth1p(3), lic1p(4), lic1p(120), auth3p(121), auth1p(122))
+        ))
+            .comparingElementsUsing(startsWithAndRangesMatch())
+            .containsExactly(
+                CI("First-party license :", 2, 4),
+                CI("First-party license :", 120, 120),
+                CI("Third-party author or owner :", 121, 121),
+                CI("First-party author or owner :", 122, 122));
+  }
+
+  @Test
+  public void testReviewComments_thirdPartyAllowed() throws Exception {
+    assertThat(
+        reviewApi.reviewComments(
+            "project",
+            THIRD_PARTY, // 3p license and 1p license or author is 3p
+            true,
+            ImmutableList.of(lic3p(2), auth3p(3), lic3p(10), auth3p(200), auth3p(210), lic1p(211))
+        ))
+            .comparingElementsUsing(startsWithAndRangesMatch())
+            .containsExactly(
+                CI("Third-party license allowed", 2, 10),
+                CI("Third-party author or owner allowed", 200, 210),
+                CI("First-party license :", 211, 211));
+  }
+
+  @Test
+  public void testReviewComments_thirdPartyNotAllowed() throws Exception {
+    assertThat(
+        reviewApi.reviewComments(
+            "project",
+            THIRD_PARTY, // 3p license and 1p license or author is 3p
+            false,
+            ImmutableList.of(lic3p(2), auth3p(3), lic3p(10), auth3p(200), auth3p(210), auth1p(211))
+        ))
+            .comparingElementsUsing(startsWithAndRangesMatch())
+            .containsExactly(
+                CI("Third-party license disallowed", 2, 10),
+                CI("Third-party author or owner disallowed", 200, 210),
+                CI("First-party author or owner :", 211, 211));
+  }
+
+  @Test
+  public void testReviewComments_forbiddenAuthor() throws Exception {
+    assertThat(
+        reviewApi.reviewComments(
+            "project",
+            FORBIDDEN, // forbidden author and anything else is still forbidden
+            false,
+            ImmutableList.of(lic1p(2), auth1p(3), lic1p(4), lic1p(120), authForbidden(121))
+        ))
+            .comparingElementsUsing(startsWithAndRangesMatch())
+            .containsExactly(
+                CI("First-party license :", 2, 4),
+                CI("First-party license :", 120, 120),
+                CI("Disapproved author or owner :", 121, 121));
+  }
+
+  @Test
+  public void testReviewComments_forbiddenLicense() throws Exception {
+    assertThat(
+        reviewApi.reviewComments(
+            "project",
+            FORBIDDEN, // forbidden license and anything else is still forbidden
+            false,
+            ImmutableList.of(lic1p(2), auth1p(3), lic1p(4), lic1p(120), licForbidden(121))
+        ))
+            .comparingElementsUsing(startsWithAndRangesMatch())
+            .containsExactly(
+                CI("First-party license :", 2, 4),
+                CI("First-party license :", 120, 120),
+                CI("Disapproved license :", 121, 121));
+  }
+
+  @Test
+  public void testReviewComments_unknownLicense() throws Exception {
+    assertThat(
+        reviewApi.reviewComments(
+            "project",
+            FORBIDDEN, // an unknown license could be forbidden so always requires review
+            false,
+            ImmutableList.of(lic1p(2), auth1p(3), lic1p(4), lic1p(120), licUnknown(121))
+        ))
+            .comparingElementsUsing(startsWithAndRangesMatch())
+            .containsExactly(
+                CI("First-party license :", 2, 4),
+                CI("First-party license :", 120, 120),
+                CI("Unrecognized license :", 121, 121));
+  }
+
+  @Test
+  public void testPartyType_firstPartyLicense() throws Exception {
+    // 1p license with 3p author is 1p in open-source
+    assertThat(
+        reviewApi.partyType(ImmutableList.of(lic1p(1), auth3p(2), lic1p(3)))
+    ).isEqualTo(FIRST_PARTY);
+  }
+
+  @Test
+  public void testPartyType_firstPartyOwner() throws Exception {
+    assertThat(
+        reviewApi.partyType(ImmutableList.of(auth1p(1), auth1p(2)))
+    ).isEqualTo(FIRST_PARTY);
+  }
+
+  @Test
+  public void testPartyType_thirdPartyLicense() throws Exception {
+    // 3p license with 1p license or author is 3p
+    assertThat(
+        reviewApi.partyType(ImmutableList.of(lic3p(1), lic1p(3), auth1p(4)))
+    ).isEqualTo(THIRD_PARTY);
+  }
+
+  @Test
+  public void testPartyType_thirdPartyOwner() throws Exception {
+    // 3p author and 1p author without any license is 3p
+    assertThat(
+        reviewApi.partyType(ImmutableList.of(auth3p(1), auth1p(2)))
+    ).isEqualTo(THIRD_PARTY);
+  }
+
+  @Test
+  public void testPartyType_forbiddenLicense() throws Exception {
+    // forbidden anything with anything else in any combination is forbidden
+    assertThat(
+        reviewApi.partyType(
+            ImmutableList.of(
+                licForbidden(1),
+                licUnknown(2),
+                lic3p(3),
+                auth3p(4),
+                lic1p(5),
+                auth1p(6)))
+    ).isEqualTo(FORBIDDEN);
+  }
+
+  @Test
+  public void testPartyType_forbiddenOwner() throws Exception {
+    // forbidden anything with anything else in any combination is forbidden
+    assertThat(
+        reviewApi.partyType(
+            ImmutableList.of(
+                authForbidden(1),
+                licUnknown(2),
+                lic3p(3),
+                auth3p(4),
+                lic1p(5),
+                auth1p(6)))
+    ).isEqualTo(FORBIDDEN);
+  }
+
+  @Test
+  public void testPartyType_unknownLicense() throws Exception {
+    // unknown license with anything but forbidden is unknown (possibly forbidden)
+    assertThat(
+        reviewApi.partyType(
+            ImmutableList.of(
+                licUnknown(2),
+                lic3p(3),
+                auth3p(4),
+                lic1p(5),
+                auth1p(6)))
+    ).isEqualTo(UNKNOWN);
+  }
+
+  private static class FakePluginUser extends PluginUser {
+    FakePluginUser() {
+      super("copyright-test");
+    }
+  }
+
+  private static class FakeCurrentUser extends CurrentUser {
+    @Override
+    public Object getCacheKey() {
+      return "31415966";
+    }
+    @Override
+    public GroupMembership getEffectiveGroups() {
+      return null;
+    }
+  }
+
+  private static Correspondence<CommentInput, CommentInput> startsWithAndRangesMatch() {
+    return new Correspondence<CommentInput, CommentInput>() {
+        @Override
+        public boolean compare(CommentInput actual, CommentInput expected) {
+          return actual.range.startLine == expected.range.startLine
+              && actual.range.endLine == expected.range.endLine
+              && actual.message.startsWith(expected.message);
+        }
+
+        @Override
+        public String toString() {
+          return "starts with and ranges match";
+        }
+      };
+  }
+
+  private static Correspondence<AddReviewerInput, String> addressedTo() {
+    return new Correspondence<AddReviewerInput, String>() {
+        @Override
+        public boolean compare(AddReviewerInput actual, String expected) {
+          return expected.equals(actual.state().toString() + ":" + actual.reviewer);
+        }
+
+        @Override
+        public String toString() {
+          return "addressed to";
+        }
+      };
+  }
+
+  /** Comment input {@code text} from {@code start} line to {@code end} line. */
+  private CommentInput CI(String text, int start, int end) {
+    CommentInput.Range r = new CommentInput.Range();
+    r.startLine = start;
+    r.endLine = end;
+    CommentInput ci = new CommentInput();
+    ci.message = text;
+    ci.range = r;
+    return ci;
+  }
+
+  /** First-party license at {@code line} */
+  private Match lic1p(int line) {
+    return new Match(FIRST_PARTY, LICENSE, "1p license", line, line, line, line);
+  }
+
+  /** Third-party license at {@code line} */
+  private Match lic3p(int line) {
+    return new Match(THIRD_PARTY, LICENSE, "3p license", line, line, line, line);
+  }
+
+  /** Forbidden license at {@code line} */
+  private Match licForbidden(int line) {
+    return new Match(FORBIDDEN, LICENSE, "forbidden license", line, line, line, line);
+  }
+
+  /** Unknown license at {@code line} */
+  private Match licUnknown(int line) {
+    return new Match(UNKNOWN, LICENSE, "unknown license", line, line, line, line);
+  }
+
+  /** First-party author/owner at {@code line} */
+  private Match auth1p(int line) {
+    return new Match(FIRST_PARTY, AUTHOR_OWNER, "1p author", line, line, line, line);
+  }
+
+  /** Third-party author/owner at {@code line} */
+  private Match auth3p(int line) {
+    return new Match(THIRD_PARTY, AUTHOR_OWNER, "3p author", line, line, line, line);
+  }
+
+  /** Forbidden author/owner at {@code line} */
+  private Match authForbidden(int line) {
+    return new Match(FORBIDDEN, AUTHOR_OWNER, "forbidden author", line, line, line, line);
+  }
+}
diff --git a/src/test/java/com/googlesource/gerrit/plugins/copyright/lib/CopyrightScannerTest.java b/src/test/java/com/googlesource/gerrit/plugins/copyright/lib/CopyrightScannerTest.java
index 393b3b1..3aa379b 100644
--- a/src/test/java/com/googlesource/gerrit/plugins/copyright/lib/CopyrightScannerTest.java
+++ b/src/test/java/com/googlesource/gerrit/plugins/copyright/lib/CopyrightScannerTest.java
@@ -23,14 +23,11 @@
 import com.googlesource.gerrit.plugins.copyright.lib.CopyrightScanner.Match;
 import com.googlesource.gerrit.plugins.copyright.lib.CopyrightScanner.MatchType;
 import com.googlesource.gerrit.plugins.copyright.lib.CopyrightScanner.PartyType;
-import com.sun.management.HotSpotDiagnosticMXBean;
 import java.io.ByteArrayInputStream;
 import java.io.InputStream;
-import java.lang.management.ManagementFactory;
 import java.nio.charset.StandardCharsets;
 import java.util.stream.Collectors;
 import org.junit.Before;
-import org.junit.BeforeClass;
 import org.junit.Test;
 import org.junit.runner.RunWith;
 import org.junit.runners.JUnit4;