CopyrightValidator to scan uploaded files.

Copyright plugin part 3 of 3: CopyrightValidator scans each new
revision looking for copyright author/owner or license declarations.

Whenever it finds a copyright declaration that might not be allowed, it
requires special review by voting down a configured label and by adding
a configured reviewer.

e.g. If it finds a third-party license or owner outside of the projects
where third-party code is allowed, it requires special review.

If it finds a forbidden or unknown license anywhere, it requires
special review.

If it finds a first-party license--including first-party licensed code
from a third-party, it notes the findings but requires no special
review.

etc.

Change-Id: Ia272fd02b3f38c5649dfc9fd8cbe3521cdfa4efc
diff --git a/src/main/java/com/googlesource/gerrit/plugins/copyright/CheckConfig.java b/src/main/java/com/googlesource/gerrit/plugins/copyright/CheckConfig.java
index e6654c7..d9dd534 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/copyright/CheckConfig.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/copyright/CheckConfig.java
@@ -368,7 +368,7 @@
 
     sb.setLength(0);
     for (int i = 255; i >= 0; i--) {
-      sb.append(String.format("%2x%2x", 255-i, i));
+      sb.append(String.format("%2x%2x", 255 - i, i));
     }
     sb.append('\n');
     String alnum1k = sb.toString();
@@ -382,7 +382,7 @@
     sb.append('\n');
     String alpha1k = sb.toString();
 
-    for (int i = 0; i < 16; i++) {  // 16k + 48k = 64k
+    for (int i = 0; i < 16; i++) { // 16k + 48k = 64k
       sb.append(space1k);
     }
     for (int i = 0; i < 48; i++) {
@@ -507,16 +507,13 @@
               elapsedMicros / 1000000);
           System.err.println(
               "might cause problems on your server. Please compare wtih the current");
-          System.err.println(
-              "configuration, and if this configuration is significantly slower,");
+          System.err.println("configuration, and if this configuration is significantly slower,");
           System.err.println("consider changing whatever pattern might cause the problem.");
         } else { // between 1s and 2s might be needed but could at least try to do better
           System.err.printf(
               "\nAt %dms, the scan took just longer than 1 second. This\n", elapsedMicros / 1000);
-          System.err.println(
-              "configuration might work okay, but takes longer than ideal. Please");
-          System.err.println(
-              "investigate whether an added pattern is more costly than needed.");
+          System.err.println("configuration might work okay, but takes longer than ideal. Please");
+          System.err.println("investigate whether an added pattern is more costly than needed.");
         }
       } else if (elapsedMicros > 1000) {
         System.err.printf("\nScanned the test load in %dms.\n", elapsedMicros / 1000);
diff --git a/src/main/java/com/googlesource/gerrit/plugins/copyright/CopyrightConfig.java b/src/main/java/com/googlesource/gerrit/plugins/copyright/CopyrightConfig.java
index ac76aa4..11b3a3a 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/copyright/CopyrightConfig.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/copyright/CopyrightConfig.java
@@ -333,6 +333,7 @@
     } finally {
       if (trialConfig != null
           && trialConfig.scannerConfig != null
+          && !trialConfig.scannerConfig.messages.isEmpty()
           && !trialConfig.scannerConfig.hasErrors()) {
         try {
           ReviewResult result =
diff --git a/src/main/java/com/googlesource/gerrit/plugins/copyright/CopyrightReviewApi.java b/src/main/java/com/googlesource/gerrit/plugins/copyright/CopyrightReviewApi.java
index 1463db3..15a8364 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/copyright/CopyrightReviewApi.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/copyright/CopyrightReviewApi.java
@@ -196,9 +196,7 @@
         oldConfig == null
             || oldConfig.scanner == null
             || !oldConfig.scanner.equals(newConfig.scanner));
-    Preconditions.checkArgument(
-        findings.size() != 1
-            || !findings.get(0).isValid());
+    Preconditions.checkArgument(findings.size() != 1 || !findings.get(0).isValid());
     Preconditions.checkArgument(maxElapsedSeconds > 0);
 
     long startNanos = System.nanoTime();
@@ -267,8 +265,8 @@
       String pluginName, ImmutableList<CommitMessageFinding> findings, long maxElapsedSeconds) {
     Preconditions.checkArgument(
         findings.size() != 1
-        || !findings.get(0).isValid()
-        || findings.get(0).elapsedMicros > maxElapsedSeconds * 1000000);
+            || !findings.get(0).isValid()
+            || findings.get(0).elapsedMicros > maxElapsedSeconds * 1000000);
     StringBuilder sb = new StringBuilder();
     sb.append(getCommitMessageMessage(pluginName, findings, maxElapsedSeconds));
     sb.append("\n\n");
diff --git a/src/main/java/com/googlesource/gerrit/plugins/copyright/CopyrightValidator.java b/src/main/java/com/googlesource/gerrit/plugins/copyright/CopyrightValidator.java
new file mode 100644
index 0000000..cda88ce
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/copyright/CopyrightValidator.java
@@ -0,0 +1,298 @@
+// 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.CopyrightReviewApi.ALWAYS_REVIEW;
+import static com.googlesource.gerrit.plugins.copyright.ScannerConfig.KEY_ENABLE;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.Nullable;
+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.RevisionCreatedListener;
+import com.google.gerrit.extensions.registration.DynamicSet;
+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.Project;
+import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.server.config.PluginConfig;
+import com.google.gerrit.server.config.PluginConfigFactory;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.inject.AbstractModule;
+import com.google.inject.Singleton;
+import com.googlesource.gerrit.plugins.copyright.lib.CopyrightScanner.Match;
+import com.googlesource.gerrit.plugins.copyright.lib.IndexedLineReader;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.concurrent.TimeUnit;
+import javax.inject.Inject;
+import org.eclipse.jgit.lib.FileMode;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectReader;
+import org.eclipse.jgit.lib.ObjectStream;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.eclipse.jgit.treewalk.TreeWalk;
+import org.eclipse.jgit.treewalk.filter.TreeFilter;
+
+/** Listener to enforce review of copyright declarations and licenses. */
+public class CopyrightValidator implements RevisionCreatedListener {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+  private final String pluginName; // name of plugin as installed in gerrit
+  private final Metrics metrics;
+  private final GitRepositoryManager repoManager;
+  private final PluginConfigFactory pluginConfigFactory;
+  private final CopyrightReviewApi reviewApi;
+
+  private CopyrightConfig copyrightConfig;
+  private ScannerConfig scannerConfig; // configuration state for plugin
+
+  static AbstractModule module() {
+    return new AbstractModule() {
+      @Override
+      protected void configure() {
+        DynamicSet.bind(binder(), RevisionCreatedListener.class).to(CopyrightValidator.class);
+      }
+    };
+  }
+
+  @Singleton
+  private static class Metrics {
+    final Counter0 scanCount;
+    final Timer0 scanRevisionTimer;
+    final Timer0 scanFileTimer;
+    final Counter1 scanCountByProject;
+    final Timer1 scanRevisionTimerByProject;
+    final Timer1 scanFileTimerByProject;
+    final Counter1 scanCountByBranch;
+    final Timer1 scanRevisionTimerByBranch;
+    final Timer1 scanFileTimerByBranch;
+
+    @Inject
+    Metrics(MetricMaker metricMaker) {
+      Field<String> project = Field.ofString("project", "project name");
+      Field<String> branch = Field.ofString("branch", "branch name");
+      scanCount =
+          metricMaker.newCounter(
+              "plugin/copyright/scan_count",
+              new Description("Total number of copyright scans").setRate().setUnit("scans"));
+      scanRevisionTimer =
+          metricMaker.newTimer(
+              "plugin/copyright/scan_revision_latency",
+              new Description("Time spent scanning entire revisions")
+                  .setCumulative()
+                  .setUnit(Units.MILLISECONDS));
+      scanFileTimer =
+          metricMaker.newTimer(
+              "plugin/copyright/scan_file_latency",
+              new Description("Time spent scanning each file")
+                  .setCumulative()
+                  .setUnit(Units.MICROSECONDS));
+      scanCountByProject =
+          metricMaker.newCounter(
+              "plugin/copyright/scan_count_by_project",
+              new Description("Total number of copyright scans").setRate().setUnit("scans"),
+              project);
+      scanRevisionTimerByProject =
+          metricMaker.newTimer(
+              "plugin/copyright/scan_revision_latency_by_project",
+              new Description("Time spent scanning entire revisions")
+                  .setCumulative()
+                  .setUnit(Units.MILLISECONDS),
+              project);
+      scanFileTimerByProject =
+          metricMaker.newTimer(
+              "plugin/copyright/scan_file_latency_by_project",
+              new Description("Time spent scanning each file")
+                  .setCumulative()
+                  .setUnit(Units.MICROSECONDS),
+              project);
+      scanCountByBranch =
+          metricMaker.newCounter(
+              "plugin/copyright/scan_count_by_branch",
+              new Description("Total number of copyright scans").setRate().setUnit("scans"),
+              branch);
+      scanRevisionTimerByBranch =
+          metricMaker.newTimer(
+              "plugin/copyright/scan_revision_latency_by_branch",
+              new Description("Time spent scanning entire revisions")
+                  .setCumulative()
+                  .setUnit(Units.MILLISECONDS),
+              branch);
+      scanFileTimerByBranch =
+          metricMaker.newTimer(
+              "plugin/copyright/scan_file_latency_by_branch",
+              new Description("Time spent scanning each file")
+                  .setCumulative()
+                  .setUnit(Units.MICROSECONDS),
+              branch);
+    }
+  }
+
+  @Inject
+  CopyrightValidator(
+      Metrics metrics,
+      @PluginName String pluginName,
+      GitRepositoryManager repoManager,
+      CopyrightConfig copyrightConfig,
+      @Nullable ScannerConfig scannerConfig,
+      PluginConfigFactory pluginConfigFactory,
+      CopyrightReviewApi reviewApi) {
+    this.metrics = metrics;
+    this.pluginName = pluginName;
+    this.repoManager = repoManager;
+    this.copyrightConfig = copyrightConfig;
+    this.scannerConfig = scannerConfig;
+    this.pluginConfigFactory = pluginConfigFactory;
+    this.reviewApi = reviewApi;
+  }
+
+  @Override
+  public void onRevisionCreated(RevisionCreatedListener.Event event) {
+    String project = event.getChange().project;
+    String branch = event.getChange().branch;
+
+    if (branch.startsWith(RefNames.REFS)) {
+      // do not scan refs
+      return;
+    }
+    PluginConfig gerritConfig = pluginConfigFactory.getFromGerritConfig(pluginName, true);
+    if (gerritConfig == null || !gerritConfig.getBoolean(KEY_ENABLE, false)) {
+      logger.atFine().log("copyright plugin not enbled");
+      return;
+    }
+    if (scannerConfig == null) {
+      // error already reported during configuration load
+      logger.atWarning().log(
+          "%s plugin enabled with no configuration -- not scanning revision %s",
+          pluginName, event.getChange().currentRevision);
+      return;
+    }
+    if (scannerConfig.scanner == null) {
+      if (scannerConfig.hasErrors()) {
+        // error already reported during configuration load
+        logger.atWarning().log(
+            "%s plugin enabled with errors in configuration -- not scanning revision %s",
+            pluginName, event.getChange().currentRevision);
+      } // else plugin not enabled
+      return;
+    }
+    // allow project override of All-Projects to enable or disable
+    if (!copyrightConfig.isProjectEnabled(scannerConfig, project)) {
+      return;
+    }
+    try {
+      scanRevision(project, branch, event);
+    } catch (IOException | RestApiException e) {
+      logger.atSevere().withCause(e).log(
+          "%s plugin cannot scan revision %s", pluginName, event.getChange().currentRevision);
+      return;
+    }
+  }
+
+  /**
+   * Scans a pushed revision reporting all findings on its review thread.
+   *
+   * @param project the project or repository to which the change was pushed
+   * @param branch the branch the change updates
+   * @param event describes the newly created revision triggering the scan
+   * @throws IOException if an error occurred reading the repository
+   * @throws RestApiException if an error occured reporting findings to the review thread
+   */
+  private void scanRevision(String project, String branch, RevisionCreatedListener.Event event)
+      throws IOException, RestApiException {
+    Map<String, ImmutableList<Match>> findings = new HashMap<>();
+    ArrayList<String> containedPaths = new ArrayList<>();
+    long scanStart = System.nanoTime();
+    metrics.scanCount.increment();
+    metrics.scanCountByProject.increment(project);
+    metrics.scanCountByBranch.increment(branch);
+
+    try (Repository repo = repoManager.openRepository(new Project.NameKey(project));
+        RevWalk revWalk = new RevWalk(repo);
+        TreeWalk tw = new TreeWalk(revWalk.getObjectReader())) {
+      RevCommit commit = repo.parseCommit(ObjectId.fromString(event.getRevision().commit.commit));
+      tw.setRecursive(true);
+      tw.setFilter(TreeFilter.ANY_DIFF);
+      tw.addTree(commit.getTree());
+      if (commit.getParentCount() > 0) {
+        for (RevCommit p : commit.getParents()) {
+          if (p.getTree() == null) {
+            revWalk.parseHeaders(p);
+          }
+          tw.addTree(p.getTree());
+        }
+      }
+      while (tw.next()) {
+        containedPaths.add(tw.getPathString());
+        String fullPath = project + "/" + tw.getPathString();
+        if (scannerConfig.isAlwaysReviewPath(fullPath)) {
+          findings.put(tw.getPathString(), ALWAYS_REVIEW);
+          continue;
+        }
+        if (!FileMode.EXECUTABLE_FILE.equals(tw.getFileMode())
+            && !FileMode.REGULAR_FILE.equals(tw.getFileMode())) {
+          continue;
+        }
+        long fileStart = System.nanoTime();
+        try (ObjectReader reader = tw.getObjectReader();
+            ObjectStream stream = reader.open(tw.getObjectId(0)).openStream()) {
+          IndexedLineReader lineReader = new IndexedLineReader(fullPath, -1, stream);
+          ImmutableList<Match> matches =
+              scannerConfig.scanner.findMatches(fullPath, -1, lineReader);
+          if (!matches.isEmpty()) {
+            findings.put(tw.getPathString(), matches);
+          }
+          long fileTime = (System.nanoTime() - fileStart) / 1000;
+          metrics.scanFileTimer.record(fileTime, TimeUnit.MICROSECONDS);
+          metrics.scanFileTimerByProject.record(project, fileTime, TimeUnit.MICROSECONDS);
+          metrics.scanFileTimerByBranch.record(branch, fileTime, TimeUnit.MICROSECONDS);
+        }
+      }
+    }
+    long scanTime = (System.nanoTime() - scanStart) / 1000000;
+    metrics.scanRevisionTimer.record(scanTime, TimeUnit.MILLISECONDS);
+    metrics.scanRevisionTimerByProject.record(project, scanTime, TimeUnit.MILLISECONDS);
+    metrics.scanRevisionTimerByBranch.record(branch, scanTime, TimeUnit.MILLISECONDS);
+
+    ReviewResult result = reviewApi.reportScanFindings(project, scannerConfig, event, findings);
+    if (result.error != null && !result.error.equals("")) {
+      logger.atSevere().log(
+          "%s plugin revision %s: error posting review: %s",
+          pluginName, event.getChange().currentRevision, result.error);
+    }
+    for (Map.Entry<String, AddReviewerResult> entry : result.reviewers.entrySet()) {
+      AddReviewerResult arr = entry.getValue();
+      if (arr.error != null && !arr.error.equals("")) {
+        logger.atSevere().log(
+            "%s plugin revision %s: error adding reviewer %s: %s",
+            pluginName, event.getChange().currentRevision, entry.getKey(), arr.error);
+      }
+    }
+  }
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/copyright/Module.java b/src/main/java/com/googlesource/gerrit/plugins/copyright/Module.java
index a319bd2..af872c7 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/copyright/Module.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/copyright/Module.java
@@ -21,6 +21,7 @@
   @Override
   protected void configure() {
     install(CopyrightConfig.module());
+    install(CopyrightValidator.module());
   }
 
   @Provides
diff --git a/src/test/java/com/googlesource/gerrit/plugins/copyright/CopyrightValidatorIT.java b/src/test/java/com/googlesource/gerrit/plugins/copyright/CopyrightValidatorIT.java
new file mode 100644
index 0000000..681ed26
--- /dev/null
+++ b/src/test/java/com/googlesource/gerrit/plugins/copyright/CopyrightValidatorIT.java
@@ -0,0 +1,486 @@
+// 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.ScannerConfig.KEY_CC;
+import static com.googlesource.gerrit.plugins.copyright.ScannerConfig.KEY_FROM;
+import static com.googlesource.gerrit.plugins.copyright.ScannerConfig.KEY_REVIEWER;
+import static com.googlesource.gerrit.plugins.copyright.TestConfig.LOCAL_BRANCH;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.truth.Correspondence;
+import com.google.gerrit.acceptance.GerritConfig;
+import com.google.gerrit.acceptance.GerritConfigs;
+import com.google.gerrit.acceptance.GitUtil;
+import com.google.gerrit.acceptance.LightweightPluginDaemonTest;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.TestAccount;
+import com.google.gerrit.acceptance.TestPlugin;
+import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.Comment;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.server.ServerInitiated;
+import com.google.gerrit.server.group.InternalGroup;
+import com.google.gerrit.server.group.db.GroupsUpdate;
+import com.google.gerrit.server.group.db.InternalGroupCreation;
+import com.google.gerrit.server.group.db.InternalGroupUpdate;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import java.util.Optional;
+import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
+import org.eclipse.jgit.junit.TestRepository;
+import org.junit.Before;
+import org.junit.Test;
+
+@TestPlugin(name = "copyright", sysModule = "com.googlesource.gerrit.plugins.copyright.Module")
+public class CopyrightValidatorIT extends LightweightPluginDaemonTest {
+  @Test
+  @GerritConfigs({
+    @GerritConfig(name = "plugin.copyright.enable", value = "true"),
+    @GerritConfig(name = "plugin.copyright.timeTestMax", value = "0")
+  })
+  public void testCopyrightValidator_pushNoLicense() throws Exception {
+    PushOneCommit.Result result =
+        pushFactory
+            .create(author.newIdent(), testRepo, "subject", "filename", "content")
+            .to("refs/for/master");
+
+    assertNoReviewerAdded(result);
+    assertThat(result.getChange().notes().getComments()).isEmpty();
+  }
+
+  @Test
+  @GerritConfigs({
+    @GerritConfig(name = "plugin.copyright.enable", value = "true"),
+    @GerritConfig(name = "plugin.copyright.timeTestMax", value = "0")
+  })
+  public void testCopyrightValidator_pushAlwaysReview() throws Exception {
+    PushOneCommit.Result result =
+        pushFactory
+            .create(author.newIdent(), testRepo, "subject", "PATENT", "content")
+            .to("refs/for/master");
+
+    assertReviewerAdded(result);
+    assertThat(result.getChange().notes().getComments().values())
+        .comparingElementsUsing(commentContains())
+        .containsExactly(unresolved("PATENT always requires"));
+  }
+
+  @Test
+  @GerritConfigs({
+    @GerritConfig(name = "plugin.copyright.enable", value = "true"),
+    @GerritConfig(name = "plugin.copyright.timeTestMax", value = "0")
+  })
+  public void testCopyrightValidator_pushFirstPartyOwner() throws Exception {
+    PushOneCommit.Result result =
+        pushFactory
+            .create(author.newIdent(), testRepo, "subject", "source.cpp", FIRST_PARTY_OWNER)
+            .to("refs/for/master");
+
+    assertNoReviewerAdded(result);
+    assertThat(result.getChange().notes().getComments().values())
+        .comparingElementsUsing(commentContains())
+        .containsExactly(resolved("First-party author or owner"));
+  }
+
+  @Test
+  @GerritConfigs({
+    @GerritConfig(name = "plugin.copyright.enable", value = "true"),
+    @GerritConfig(name = "plugin.copyright.timeTestMax", value = "0")
+  })
+  public void testCopyrightValidator_pushFirstPartyHeader() throws Exception {
+    PushOneCommit.Result result =
+        pushFactory
+            .create(author.newIdent(), testRepo, "subject", "source.cpp", FIRST_PARTY_HEADER)
+            .to("refs/for/master");
+
+    assertNoReviewerAdded(result);
+    assertThat(result.getChange().notes().getComments().values())
+        .comparingElementsUsing(commentContains())
+        .containsExactly(resolved("First-party license"));
+  }
+
+  @Test
+  @GerritConfigs({
+    @GerritConfig(name = "plugin.copyright.enable", value = "true"),
+    @GerritConfig(name = "plugin.copyright.timeTestMax", value = "0")
+  })
+  public void testCopyrightValidator_pushFirstPartyOwnerAndHeader() throws Exception {
+    PushOneCommit.Result result =
+        pushFactory
+            .create(
+                author.newIdent(),
+                testRepo,
+                "subject",
+                "source.cpp",
+                FIRST_PARTY_OWNER + FIRST_PARTY_HEADER)
+            .to("refs/for/master");
+
+    assertNoReviewerAdded(result);
+    assertThat(result.getChange().notes().getComments().values())
+        .comparingElementsUsing(commentContains())
+        .containsExactly(resolved("First-party license")); // owner folds into license
+  }
+
+  @Test
+  @GerritConfigs({
+    @GerritConfig(name = "plugin.copyright.enable", value = "true"),
+    @GerritConfig(name = "plugin.copyright.timeTestMax", value = "0")
+  })
+  public void testCopyrightValidator_pushFirstPartyHeaderAndOwner() throws Exception {
+    PushOneCommit.Result result =
+        pushFactory
+            .create(
+                author.newIdent(),
+                testRepo,
+                "subject",
+                "source.cpp",
+                FIRST_PARTY_HEADER + FIRST_PARTY_OWNER)
+            .to("refs/for/master");
+
+    assertNoReviewerAdded(result);
+    assertThat(result.getChange().notes().getComments().values())
+        .comparingElementsUsing(commentContains())
+        .containsExactly(resolved("First-party license")); // owner folds into license
+  }
+
+  @Test
+  @GerritConfigs({
+    @GerritConfig(name = "plugin.copyright.enable", value = "true"),
+    @GerritConfig(name = "plugin.copyright.timeTestMax", value = "0")
+  })
+  public void testCopyrightValidator_pushNotAContribHeader() throws Exception {
+    PushOneCommit.Result result =
+        pushFactory
+            .create(author.newIdent(), testRepo, "subject", "source.cpp", NOT_A_CONTRIB_HEADER)
+            .to("refs/for/master");
+
+    assertReviewerAdded(result);
+    assertThat(result.getChange().notes().getComments().values())
+        .comparingElementsUsing(commentContains())
+        .containsExactly(resolved("First-party license"), unresolved("Disapproved license"));
+  }
+
+  @Test
+  @GerritConfigs({
+    @GerritConfig(name = "plugin.copyright.enable", value = "true"),
+    @GerritConfig(name = "plugin.copyright.timeTestMax", value = "0")
+  })
+  public void testCopyrightValidator_pushThirdPartyAllowed() throws Exception {
+    PushOneCommit.Result result =
+        pushFactory
+            .create(author.newIdent(), testRepo, "subject", "LICENSE", THIRD_PARTY_MIT)
+            .to("refs/for/master");
+
+    assertNoReviewerAdded(result);
+    assertThat(result.getChange().notes().getComments().values())
+        .comparingElementsUsing(commentContains())
+        .containsExactly(resolved("Third-party license allowed"));
+  }
+
+  @Test
+  @GerritConfigs({
+    @GerritConfig(name = "plugin.copyright.enable", value = "true"),
+    @GerritConfig(name = "plugin.copyright.timeTestMax", value = "0")
+  })
+  public void testCopyrightValidator_pushThirdPartyNotAllowed() throws Exception {
+    PushOneCommit.Result result =
+        pushFactory
+            .create(author.newIdent(), testRepo, "subject", "LICENSE", THIRD_PARTY_MIT)
+            .to("refs/for/master");
+
+    assertReviewerAdded(result);
+    assertThat(result.getChange().notes().getComments().values())
+        .comparingElementsUsing(commentContains())
+        .containsExactly(unresolved("Third-party license disallowed"));
+  }
+
+  @Test
+  @GerritConfigs({
+    @GerritConfig(name = "plugin.copyright.enable", value = "true"),
+    @GerritConfig(name = "plugin.copyright.timeTestMax", value = "0")
+  })
+  public void testCopyrightValidator_pushThirdPartyOwnerAllowed() throws Exception {
+    PushOneCommit.Result result =
+        pushFactory
+            .create(author.newIdent(), testRepo, "subject", "COPYING", THIRD_PARTY_OWNER)
+            .to("refs/for/master");
+
+    assertNoReviewerAdded(result);
+    assertThat(result.getChange().notes().getComments().values())
+        .comparingElementsUsing(commentContains())
+        .containsExactly(resolved("Third-party author or owner allowed"));
+  }
+
+  @Test
+  @GerritConfigs({
+    @GerritConfig(name = "plugin.copyright.enable", value = "true"),
+    @GerritConfig(name = "plugin.copyright.timeTestMax", value = "0")
+  })
+  public void testCopyrightValidator_pushThirdPartyOwnerNotAllowed() throws Exception {
+    PushOneCommit.Result result =
+        pushFactory
+            .create(author.newIdent(), testRepo, "subject", "COPYING", THIRD_PARTY_OWNER)
+            .to("refs/for/master");
+
+    assertReviewerAdded(result);
+    assertThat(result.getChange().notes().getComments().values())
+        .comparingElementsUsing(commentContains())
+        .containsExactly(unresolved("Third-party author or owner disallowed"));
+  }
+
+  @Test
+  @GerritConfigs({
+    @GerritConfig(name = "plugin.copyright.enable", value = "true"),
+    @GerritConfig(name = "plugin.copyright.timeTestMax", value = "0")
+  })
+  public void testCopyrightValidator_pushFirstPartyLicenseThirdPartyOwner() throws Exception {
+    PushOneCommit.Result result =
+        pushFactory
+            .create(
+                author.newIdent(),
+                testRepo,
+                "subject",
+                "COPYING",
+                FIRST_PARTY_HEADER + THIRD_PARTY_OWNER)
+            .to("refs/for/master");
+
+    assertNoReviewerAdded(result);
+    assertThat(result.getChange().notes().getComments().values())
+        .comparingElementsUsing(commentContains())
+        .containsExactly(
+            resolved("First-party license"),
+            resolved("Third-party author or owner")); // 1p license from 3p author is 1p
+  }
+
+  @Test
+  @GerritConfigs({
+    @GerritConfig(name = "plugin.copyright.enable", value = "true"),
+    @GerritConfig(name = "plugin.copyright.timeTestMax", value = "0")
+  })
+  public void testCopyrightValidator_pushFirstPartyOwnerThirdPartyOwner() throws Exception {
+    PushOneCommit.Result result =
+        pushFactory
+            .create(
+                author.newIdent(),
+                testRepo,
+                "subject",
+                "COPYING",
+                FIRST_PARTY_OWNER + THIRD_PARTY_OWNER)
+            .to("refs/for/master");
+
+    assertReviewerAdded(result);
+    assertThat(result.getChange().notes().getComments().values())
+        .comparingElementsUsing(commentContains())
+        .containsExactly(
+            resolved("First-party author or owner"),
+            unresolved("Third-party author or owner disallowed"));
+  }
+
+  @Test
+  @GerritConfigs({
+    @GerritConfig(name = "plugin.copyright.enable", value = "true"),
+    @GerritConfig(name = "plugin.copyright.timeTestMax", value = "0")
+  })
+  public void testCopyrightValidator_pushThirdPartyLicenseFirstPartyOwner() throws Exception {
+    PushOneCommit.Result result =
+        pushFactory
+            .create(
+                author.newIdent(),
+                testRepo,
+                "subject",
+                "COPYING",
+                THIRD_PARTY_MIT + FIRST_PARTY_OWNER)
+            .to("refs/for/master");
+
+    assertReviewerAdded(result);
+    assertThat(result.getChange().notes().getComments().values())
+        .comparingElementsUsing(commentContains())
+        .containsExactly(
+            unresolved("Third-party license disallowed"), resolved("First-party author or owner"));
+  }
+
+  private static final String FIRST_PARTY_OWNER =
+      "// Copyright (C) 2019 The Android Open Source Project\n";
+  private static final String FIRST_PARTY_HEADER =
+      "// Licensed under the Apache License, Version 2.0 (the \"License\");\n"
+          + "// you may not use this file except in compliance with the License.\n"
+          + "// You may obtain a copy of the License at\n//\n"
+          + "// http://www.apache.org/licenses/LICENSE-2.0\n//\n"
+          + "// Unless required by applicable law or agreed to in writing, software\n"
+          + "// distributed under the License is distributed on an \"AS IS\" BASIS,\n"
+          + "// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n"
+          + "// See the License for the specific language governing permissions and\n"
+          + "// limitations under the License.\n";
+  private static final String NOT_A_CONTRIB_HEADER = FIRST_PARTY_HEADER + "Not a contribution.\n";
+  private static final String THIRD_PARTY_MIT =
+      "MIT License\n\nCopyright (c) Jane Doe\n\n"
+          + "Permission is hereby granted, free of charge, to any person obtaining a copy\n"
+          + "of this software and associated documentation files (the \"Software\"), to deal\n"
+          + "in the Software without restriction, including without limitation the rights\n"
+          + "to use, copy, modify, merge, publish, distribute, sublicense, and/or sell\n"
+          + "copies of the Software, and to permit persons to whom the Software is\n"
+          + "furnished to do so, subject to the following conditions:\n\n"
+          + "The above copyright notice and this permission notice shall be included in all\n"
+          + "copies or substantial portions of the Software.\n\n"
+          + "THE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\n"
+          + "IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\n"
+          + "FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\n"
+          + "AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\n"
+          + "LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\n"
+          + "OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\n"
+          + "SOFTWARE.\n";
+  private static final String THIRD_PARTY_OWNER = "Copyright (c) 2019 Acme Other Corp.\n";
+
+  private static int nextId = 123;
+
+  @Inject @ServerInitiated private Provider<GroupsUpdate> groupsUpdateProvider;
+
+  private InternalGroup botGroup;
+  private InternalGroup expertGroup;
+  private TestAccount pluginAccount;
+  private TestAccount reviewer;
+  private TestAccount observer;
+  private TestAccount author;
+  private String projectConfigContent;
+
+  @Before
+  public void setUp() throws Exception {
+    botGroup = testGroup("Non-Interactive Users");
+    expertGroup = testGroup("Copyright Experts");
+    pluginAccount =
+        accountCreator.create(
+            "copyright-scanner",
+            "copyright-scanner@example.com",
+            "Copyright Scanner",
+            "Non-Interactive Users",
+            expertGroup.getName());
+    reviewer =
+        accountCreator.create(
+            "lawyercat", "legal@example.com", "J. Doe J.D. LL.M. Esq.", expertGroup.getName());
+    observer = accountCreator.create("my-team", "my-team@example.com", "My Team");
+    author = accountCreator.create("author", "author@example.com", "J. Doe");
+    TestRepository<InMemoryRepository> testRepo = getTestRepo(allProjects);
+    TestConfig testConfig = new TestConfig(allProjects, plugin.getName(), admin, testRepo);
+    testConfig.copyLabel("Code-Review", "Copyright-Review");
+    testConfig.setVoters(
+        RefNames.REFS_HEADS + "*",
+        "Copyright-Review",
+        new TestConfig.Voter("Administrators", -2, +2),
+        new TestConfig.Voter(expertGroup.getNameKey().get(), -2, +2),
+        new TestConfig.Voter("Registered Users", -2, 0));
+    testConfig.addGroups(botGroup, expertGroup);
+    testConfig.updatePlugin(
+        TestConfig.BASE_CONFIG,
+        TestConfig.ENABLE_CONFIG,
+        cfg -> {
+          cfg.setStringList(KEY_REVIEWER, ImmutableList.of(reviewer.username()));
+        },
+        cfg -> {
+          cfg.setStringList(KEY_CC, ImmutableList.of(observer.username()));
+        },
+        cfg -> {
+          cfg.setInt(KEY_FROM, pluginAccount.id().get());
+        });
+    PushOneCommit.Result result = testConfig.push(pushFactory);
+    result.assertOkStatus();
+    assertThat(result.getChange().publishedComments()).isEmpty();
+    merge(result);
+  }
+
+  private AccountGroup.Id nextGroupId() {
+    return new AccountGroup.Id(nextId++);
+  }
+
+  private TestRepository<InMemoryRepository> getTestRepo(Project.NameKey projectName)
+      throws Exception {
+    TestRepository<InMemoryRepository> testRepo = cloneProject(projectName, admin);
+    GitUtil.fetch(testRepo, RefNames.REFS_CONFIG + ":" + LOCAL_BRANCH);
+    testRepo.reset(LOCAL_BRANCH);
+    return testRepo;
+  }
+
+  private InternalGroup testGroup(String name) throws Exception {
+    AccountGroup.NameKey nameKey = new AccountGroup.NameKey(name);
+    Optional<InternalGroup> g = groupCache.get(nameKey);
+    if (g.isPresent()) {
+      return g.get();
+    }
+    GroupsUpdate groupsUpdate = groupsUpdateProvider.get();
+    InternalGroupCreation gc =
+        InternalGroupCreation.builder()
+            .setGroupUUID(new AccountGroup.UUID("users-" + name.replace(" ", "_")))
+            .setNameKey(nameKey)
+            .setId(nextGroupId())
+            .build();
+    InternalGroupUpdate gu = InternalGroupUpdate.builder().setName(nameKey).build();
+    return groupsUpdate.createGroup(gc, gu);
+  }
+
+  private void assertReviewerAdded(PushOneCommit.Result result) throws Exception {
+    result.assertOkStatus();
+    result.assertChange(
+        Change.Status.NEW,
+        null,
+        ImmutableList.of(author, reviewer),
+        ImmutableList.of(pluginAccount, observer));
+  }
+
+  private void assertNoReviewerAdded(PushOneCommit.Result result) throws Exception {
+    result.assertOkStatus();
+    result.assertChange(
+        Change.Status.NEW, null, ImmutableList.of(author), ImmutableList.of(pluginAccount));
+  }
+
+  private CommentMatch resolved(String content) {
+    return new CommentMatch(true /* resolved */, content);
+  }
+
+  private CommentMatch unresolved(String content) {
+    return new CommentMatch(false /* resolved */, content);
+  }
+
+  private static Correspondence<Comment, CommentMatch> commentContains() {
+    return new Correspondence<Comment, CommentMatch>() {
+      @Override
+      public boolean compare(Comment actual, CommentMatch expected) {
+        return actual.unresolved != expected.resolved && actual.message.contains(expected.content);
+      }
+
+      @Override
+      public String toString() {
+        return "comment resolution status and content matches";
+      }
+    };
+  }
+
+  private static class CommentMatch {
+    boolean resolved;
+    String content;
+
+    CommentMatch(boolean resolved, String content) {
+      this.resolved = resolved;
+      this.content = content;
+    }
+
+    @Override
+    public String toString() {
+      return (resolved ? "" : "un") + "resolved(\"" + content + "\")";
+    }
+  }
+}
diff --git a/src/test/java/com/googlesource/gerrit/plugins/copyright/TestConfig.java b/src/test/java/com/googlesource/gerrit/plugins/copyright/TestConfig.java
index a8fc2c5..f8aab70 100644
--- a/src/test/java/com/googlesource/gerrit/plugins/copyright/TestConfig.java
+++ b/src/test/java/com/googlesource/gerrit/plugins/copyright/TestConfig.java
@@ -15,6 +15,7 @@
 package com.googlesource.gerrit.plugins.copyright;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.googlesource.gerrit.plugins.copyright.ScannerConfig.KEY_ALWAYS_REVIEW_PATH;
 import static com.googlesource.gerrit.plugins.copyright.ScannerConfig.KEY_ENABLE;
 import static com.googlesource.gerrit.plugins.copyright.ScannerConfig.KEY_EXCLUDE;
 import static com.googlesource.gerrit.plugins.copyright.ScannerConfig.KEY_FIRST_PARTY;
@@ -22,6 +23,7 @@
 import static com.googlesource.gerrit.plugins.copyright.ScannerConfig.KEY_FORBIDDEN_PATTERN;
 import static com.googlesource.gerrit.plugins.copyright.ScannerConfig.KEY_REVIEW_LABEL;
 import static com.googlesource.gerrit.plugins.copyright.ScannerConfig.KEY_THIRD_PARTY;
+import static com.googlesource.gerrit.plugins.copyright.ScannerConfig.KEY_THIRD_PARTY_ALLOWED_PROJECTS;
 import static java.nio.charset.StandardCharsets.UTF_8;
 
 import com.google.common.collect.ImmutableList;
@@ -82,6 +84,9 @@
 
   static final Consumer<PluginConfig> BASE_CONFIG =
       pCfg -> {
+        pCfg.setStringList(KEY_ALWAYS_REVIEW_PATH, ImmutableList.of("PATENT$"));
+        pCfg.setStringList(
+            KEY_THIRD_PARTY_ALLOWED_PROJECTS, ImmutableList.of("ThirdParty(?:Owner)?Allowed"));
         pCfg.setString(KEY_REVIEW_LABEL, "Copyright-Review");
         pCfg.setStringList(KEY_EXCLUDE, ImmutableList.of("EXAMPLES"));
         pCfg.setStringList(