CopyrightConfig to manage plugin configurations.
Copyright plugin part 2 of 3: CopyrightConfig manages and validates
plugin configurations described in the gerrit.config file, and in the
All-Projects project.config and groups files.
ScannerConfig wraps all of the parameters needed for scanning files.
CheckConfig wraps facilitates validation of the configuration. It also
provides the main entry point for a command-line tool to check for
performance-harming regular expression patterns.
When a new configuration changes the scanner pattern, the gerrit server
may be configured to require the submitter run the command-line tool
and copy its output to the commit message. By default, the server will
require this process, which can be turned off by making the timeTestMax
value 0.
Change-Id: I82c034be01b9b33b1f57041627d6065e27b021db
diff --git a/BUILD b/BUILD
index 73cc5c5..f36a951 100644
--- a/BUILD
+++ b/BUILD
@@ -30,7 +30,30 @@
deps = [":copyright_scanner"],
)
-TEST_SRCS = "src/test/java/**/*Test.java"
+java_binary(
+ name = "check_new_config",
+ srcs = ["src/main/java/com/googlesource/gerrit/plugins/copyright/CheckConfig.java"],
+ main_class = "com.googlesource.gerrit.plugins.copyright.CheckConfig",
+ deps = [":copyright_scanner"] + PLUGIN_DEPS,
+)
+
+gerrit_plugin(
+ name = "copyright",
+ srcs = glob(["src/main/java/**/*.java"]),
+ manifest_entries = [
+ "Gerrit-PluginName: copyright",
+ "Gerrit-ReloadMode: restart",
+ "Gerrit-ApiVersion: 3.0-SNAPSHOT",
+ "Gerrit-ApiType: plugin",
+ "Gerrit-Module: com.googlesource.gerrit.plugins.copyright.Module",
+ ],
+ resources = glob(["src/main/resources/**/*"]),
+)
+
+TEST_SRCS = [
+ "src/test/java/**/*IT.java",
+ "src/test/java/**/*Test.java",
+]
TEST_DEPS = PLUGIN_DEPS + PLUGIN_TEST_DEPS + [
":copyright_scanner",
@@ -39,12 +62,22 @@
"@mockito//jar",
]
+java_library(
+ name = "testutils",
+ testonly = 1,
+ srcs = glob(
+ ["src/test/java/**/*.java"],
+ exclude = TEST_SRCS,
+ ),
+ deps = TEST_DEPS,
+)
+
junit_tests(
name = "copyright_scanner_tests",
testonly = 1,
- srcs = glob([TEST_SRCS]),
+ srcs = glob(TEST_SRCS),
tags = ["copyright"],
- deps = TEST_DEPS,
+ deps = [":testutils"] + TEST_DEPS,
)
sh_test(
diff --git a/src/main/java/com/googlesource/gerrit/plugins/copyright/CheckConfig.java b/src/main/java/com/googlesource/gerrit/plugins/copyright/CheckConfig.java
new file mode 100644
index 0000000..e6654c7
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/copyright/CheckConfig.java
@@ -0,0 +1,539 @@
+// 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 java.nio.charset.StandardCharsets.UTF_8;
+
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.base.Preconditions;
+import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableList;
+import com.google.common.flogger.FluentLogger;
+import com.google.common.hash.Hashing;
+import com.google.gerrit.reviewdb.client.Account;
+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.PluginConfig;
+import com.google.gerrit.server.git.validators.CommitValidationMessage;
+import com.google.gerrit.server.git.validators.ValidationMessage;
+import com.googlesource.gerrit.plugins.copyright.lib.IndexedLineReader;
+import java.io.ByteArrayInputStream;
+import java.io.FileInputStream;
+import java.io.IOException;
+import java.nio.CharBuffer;
+import java.nio.file.Paths;
+import java.util.Arrays;
+import java.util.Objects;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.Config;
+
+/**
+ * Utility for verifying copyright plugin configurations.
+ *
+ * <p>{@code main} implements a command-line tool for measuring performance against a large test
+ * file constructed to trigger excessive backtracking.
+ */
+public class CheckConfig {
+ private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+ public static final String toolName = "check_new_config";
+
+ private static final String ACCESS = "access";
+ private static final String LABEL = "label";
+ private static final String PLUGIN = "plugin";
+ private static final int BUFFER_SIZE = 2048;
+ private static final char[] BUFFER = new char[BUFFER_SIZE];
+
+ private String pluginName;
+ private Config configProject;
+ ScannerConfig scannerConfig;
+
+ public CheckConfig(String pluginName, String projectConfigContents)
+ throws ConfigInvalidException {
+ this.pluginName = pluginName;
+
+ configProject = new Config();
+ configProject.fromText(projectConfigContents);
+ Config config = new Config();
+ for (String name : configProject.getNames(PLUGIN, pluginName)) {
+ config.setStringList(
+ PLUGIN,
+ pluginName,
+ name,
+ Arrays.asList(configProject.getStringList(PLUGIN, pluginName, name)));
+ }
+ PluginConfig pluginConfig = new PluginConfig(pluginName, config);
+ this.scannerConfig = new ScannerConfig(pluginName);
+ this.scannerConfig.readConfigFile(pluginConfig);
+ }
+
+ @Override
+ public boolean equals(Object other) {
+ if (this == other) {
+ return true;
+ }
+ if (other == null) {
+ return false;
+ }
+ if (other instanceof CheckConfig) {
+ CheckConfig that = (CheckConfig) other;
+ return Objects.equals(this.pluginName, that.pluginName)
+ && Objects.equals(this.scannerConfig, that.scannerConfig);
+ }
+ return false;
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(pluginName, scannerConfig);
+ }
+
+ /**
+ * Validates the final state of {@code trialConfig}.
+ *
+ * <p>Uses {@code reviewApi}, when given, to verify account information. When {@code
+ * pluginEnabled} is false, treats errors as warnings.
+ */
+ public static void checkProjectConfig(
+ CopyrightReviewApi reviewApi, boolean pluginEnabled, CheckConfig trialConfig) {
+ CurrentUser fromUser =
+ reviewApi == null
+ ? null
+ : reviewApi.getSendingUser(trialConfig.scannerConfig.fromAccountId);
+ if (Strings.nullToEmpty(trialConfig.scannerConfig.reviewLabel).trim().isEmpty()) {
+ trialConfig.scannerConfig.messages.add(
+ new CommitValidationMessage(
+ trialConfig.scannerConfig.pluginKeyRequired(
+ ScannerConfig.KEY_REVIEW_LABEL,
+ "please use \""
+ + ScannerConfig.KEY_REVIEW_LABEL
+ + " = <label name>\" to identify the label "
+ + (trialConfig.scannerConfig.fromAccountId < 1 || fromUser == null
+ ? "the plugin"
+ : fromUser.getLoggableName())
+ + " will vote on"),
+ pluginEnabled ? ValidationMessage.Type.ERROR : ValidationMessage.Type.WARNING));
+ } else {
+ String labelName = trialConfig.scannerConfig.reviewLabel.trim();
+ if (!trialConfig.configProject.getSubsections(LABEL).contains(labelName)) {
+ trialConfig.scannerConfig.messages.add(
+ new CommitValidationMessage(
+ trialConfig.scannerConfig.pluginKeyValueMessage(
+ ScannerConfig.KEY_REVIEW_LABEL,
+ labelName,
+ "no [" + LABEL + " \"" + labelName + "\"] section configured."),
+ pluginEnabled ? ValidationMessage.Type.ERROR : ValidationMessage.Type.WARNING));
+ }
+ String[] voters =
+ trialConfig.configProject.getStringList(
+ ACCESS, RefNames.REFS_HEADS + "*", "label-" + labelName);
+ boolean foundApprover = false;
+ for (String voter : voters) {
+ if (voter.trim().split("\\s", 2)[0].endsWith("+2")) {
+ foundApprover = true;
+ break;
+ }
+ }
+ if (!foundApprover) {
+ trialConfig.scannerConfig.messages.add(
+ new CommitValidationMessage(
+ trialConfig.scannerConfig.pluginKeyValueMessage(
+ ScannerConfig.KEY_REVIEW_LABEL,
+ labelName,
+ "no configured approvers for "
+ + labelName
+ + " on "
+ + RefNames.REFS_HEADS
+ + "*"),
+ pluginEnabled ? ValidationMessage.Type.ERROR : ValidationMessage.Type.WARNING));
+ }
+ voters =
+ trialConfig.configProject.getStringList(
+ ACCESS, RefNames.REFS_CONFIG, "label-" + labelName);
+ foundApprover = false;
+ for (String voter : voters) {
+ if (voter.trim().split("\\s", 2)[0].endsWith("+2")) {
+ foundApprover = true;
+ break;
+ }
+ }
+ if (!foundApprover) {
+ trialConfig.scannerConfig.messages.add(
+ new CommitValidationMessage(
+ trialConfig.scannerConfig.pluginKeyValueMessage(
+ ScannerConfig.KEY_REVIEW_LABEL,
+ labelName,
+ "no configured approvers for " + labelName + " on " + RefNames.REFS_CONFIG),
+ pluginEnabled ? ValidationMessage.Type.ERROR : ValidationMessage.Type.WARNING));
+ }
+ }
+ if (trialConfig.scannerConfig.reviewers.isEmpty()) {
+ trialConfig.scannerConfig.messages.add(
+ ScannerConfig.warningMessage(
+ trialConfig.scannerConfig.pluginKeyRequired(
+ ScannerConfig.KEY_REVIEWER, "adding no qualified reviewer may cause confusion")));
+ }
+ if (trialConfig.scannerConfig.fromAccountId < 1) {
+ trialConfig.scannerConfig.messages.add(
+ new CommitValidationMessage(
+ trialConfig.scannerConfig.pluginKeyRequired(
+ ScannerConfig.KEY_FROM,
+ "please use \""
+ + ScannerConfig.KEY_FROM
+ + " = <account id>\" to identify a"
+ + " non-interactive user with full voting permissions for the review label '"
+ + trialConfig.scannerConfig.reviewLabel
+ + "'"),
+ pluginEnabled ? ValidationMessage.Type.ERROR : ValidationMessage.Type.WARNING));
+ // TODO: inject ReviewerAdder into reviewApi, and use ReviewerAdder.prepare
+ // a la
+ // https://gerrit.googlesource.com/gerrit/+/refs/heads/master/java/com/google/gerrit/server/restapi/change/PostReview.java#265
+ // to verify the reviewers and ccs are valid.
+ }
+ if (fromUser != null && fromUser instanceof IdentifiedUser) {
+ IdentifiedUser sendingUser = (IdentifiedUser) fromUser;
+ Account account = sendingUser.getAccount();
+ if (Strings.isNullOrEmpty(account.getFullName())
+ && Strings.isNullOrEmpty(account.getPreferredEmail())) {
+ trialConfig.scannerConfig.messages.add(
+ ScannerConfig.warningMessage(
+ trialConfig.scannerConfig.pluginKeyValueMessage(
+ ScannerConfig.KEY_FROM,
+ Long.toString(trialConfig.scannerConfig.fromAccountId),
+ fromUser.getLoggableName()
+ + " (account id "
+ + trialConfig.scannerConfig.fromAccountId
+ + ") has no full name or preferred email")));
+ }
+ if (!account.isActive()) {
+ trialConfig.scannerConfig.messages.add(
+ ScannerConfig.warningMessage(
+ trialConfig.scannerConfig.pluginKeyValueMessage(
+ ScannerConfig.KEY_FROM,
+ Long.toString(trialConfig.scannerConfig.fromAccountId),
+ fromUser.getLoggableName()
+ + " (account id "
+ + trialConfig.scannerConfig.fromAccountId
+ + ") account is no longer active")));
+ }
+ } else if (fromUser != null && !fromUser.getUserName().isPresent()) {
+ trialConfig.scannerConfig.messages.add(
+ ScannerConfig.warningMessage(
+ trialConfig.scannerConfig.pluginKeyValueMessage(
+ ScannerConfig.KEY_FROM,
+ Long.toString(trialConfig.scannerConfig.fromAccountId),
+ fromUser.getLoggableName()
+ + " (account id "
+ + trialConfig.scannerConfig.fromAccountId
+ + ") has no user name")));
+ }
+ }
+
+ /**
+ * Confirms whether submitter ran {@code main} and copied the output to the commit message.
+ *
+ * <p>When a new commit alters the configured scanner patterns, the push will fail with a message
+ * to download the plugin source, to run a shell script that runs {@code main} below, and to copy
+ * the output on success into the commit message.
+ *
+ * <p>This method scans the commit message to find the copied text. If the text was created for
+ * the same pattern signagure, this method returns a single valid finding with the number of
+ * microseconds it took to scan a large file, which can be used to block patterns that cause
+ * excessive backtracking.
+ *
+ * <p>If the commit message contains one or more copied texts for other pattern signatures, this
+ * method retuns an invalid finding for each.
+ *
+ * <p>If the commit message contains no copied texts, this method returns an empty list of
+ * findings, which {@link com.googlesource.gerrit.plugins.copyright.CopyrightConfig} uses as a
+ * signal to instruct the submitter to run the shell script in the first place.
+ *
+ * @param commitMessage from the pushed change
+ */
+ public ImmutableList<CopyrightReviewApi.CommitMessageFinding> checkCommitMessage(
+ String commitMessage) {
+ Preconditions.checkArgument(hasScanner(this));
+
+ Pattern pattern =
+ Pattern.compile(
+ "Copyright-check:\\s*([\\p{N}a-fA-F]{1,9})[.]([\\p{N}a-fA-F]{16})\\s*(?:\n|$)",
+ Pattern.CASE_INSENSITIVE | Pattern.MULTILINE | Pattern.UNICODE_CASE | Pattern.DOTALL);
+ Matcher m = pattern.matcher(commitMessage);
+ ImmutableList.Builder<CopyrightReviewApi.CommitMessageFinding> builder =
+ ImmutableList.builder();
+ StringBuilder sb = new StringBuilder();
+ while (m.find()) {
+ sb.setLength(0);
+ sb.append(m.group(1));
+ sb.append("us");
+ sb.append(scannerConfig.patternSignature);
+ String signature =
+ Hashing.farmHashFingerprint64().hashBytes(sb.toString().getBytes(UTF_8)).toString();
+ if (signature.equals(m.group(2))) {
+ return ImmutableList.of(
+ new CopyrightReviewApi.CommitMessageFinding(
+ commitMessage, m.group(), m.group(1), m.start(), m.end()));
+ }
+ logger.atSevere().log("signature for %s is %s", m.group(1), signature);
+ builder.add(
+ new CopyrightReviewApi.CommitMessageFinding(
+ commitMessage, m.group(), m.start(), m.end()));
+ }
+ return builder.build();
+ }
+
+ /**
+ * Returns true when {@code findings} indicate a problem to correct.
+ *
+ * <p>Problems include finding no time signature, finding a time signature for a different commit,
+ * or finding a valid time signature for a test that took longer than {@code maxElapsedSeconds}.
+ */
+ public static boolean mustReportFindings(
+ ImmutableList<CopyrightReviewApi.CommitMessageFinding> findings, long maxElapsedSeconds) {
+ if (findings.size() == 1 && findings.get(0).isValid()) {
+ return findings.get(0).elapsedMicros > maxElapsedSeconds * 1000000;
+ }
+ return true;
+ }
+
+ /** Returns true when {@code trialConfig} is non-null and has a non-null {@code scanner}. */
+ public static boolean hasScanner(CheckConfig trialConfig) {
+ if (trialConfig == null || trialConfig.scannerConfig == null) {
+ return false;
+ }
+ return trialConfig.scannerConfig.scanner != null;
+ }
+
+ /** Returns true when both configs, {@code a} and {@code b}, have the same scanner pattern. */
+ public static boolean scannersEqual(CheckConfig a, CheckConfig b) {
+ if (!hasScanner(a)) {
+ return !hasScanner(b);
+ }
+ return Objects.equals(a.scannerConfig.scanner, b.scannerConfig.scanner);
+ }
+
+ /** Checks whether {@code trialConfig} might cause excessive backtracking. */
+ private long timeLargeFileInMicros() throws IOException {
+ long startNanos = System.nanoTime();
+ try {
+ IndexedLineReader file = largeFile();
+ scannerConfig.scanner.findMatches("file", -1, file);
+ } finally {
+ long elapsedNanos = System.nanoTime() - startNanos;
+ long elapsedMicros = elapsedNanos / 1000;
+ logger.atFine().log("timeLargeFile %dms", elapsedMicros / 1000);
+ return elapsedMicros;
+ }
+ }
+
+ /** Returns {@code IndexedLineReader} for in-memory pattern that can trigger backtracking. */
+ private IndexedLineReader largeFile() {
+ StringBuilder sb = new StringBuilder();
+ sb.append(" "); // 64 spaces
+ sb.append(sb); // 128
+ sb.append(sb); // 256
+ sb.append(sb); // 512
+ sb.append(sb); // 1024
+ sb.append('\n');
+ String space1k = sb.toString();
+
+ sb.setLength(0);
+ for (int i = 0; i < 256; i++) {
+ sb.append(String.format(" x%2x", i));
+ }
+ sb.append('\n');
+ String mixed1k = sb.toString();
+
+ sb.setLength(0);
+ for (int i = 255; i >= 0; i--) {
+ sb.append(String.format(", %2x", i));
+ }
+ sb.append('\n');
+ String comma1k = sb.toString();
+
+ sb.setLength(0);
+ for (int i = 255; i >= 0; i--) {
+ sb.append(String.format("%2x%2x", 255-i, i));
+ }
+ sb.append('\n');
+ String alnum1k = sb.toString();
+
+ sb.setLength(0);
+ sb.append("AbcdefGhijkLmnopQrstuVwxyzaBCDEFgHIJKlMNOPqRSTUvWXYZaeiouyAEIUOY"); // 64
+ sb.append(sb); // 128
+ sb.append(sb); // 256
+ sb.append(sb); // 512
+ sb.append(sb); // 1024
+ sb.append('\n');
+ String alpha1k = sb.toString();
+
+ for (int i = 0; i < 16; i++) { // 16k + 48k = 64k
+ sb.append(space1k);
+ }
+ for (int i = 0; i < 48; i++) {
+ sb.append(space1k, 0, 512 - 10 * i);
+ sb.append(mixed1k, 512 + 10 * i, mixed1k.length());
+ }
+
+ for (int i = 0; i < 16; i++) { // 64k + 16k + 48k = 128k
+ sb.append(alnum1k);
+ }
+ for (int i = 0; i < 48; i++) {
+ sb.append(comma1k);
+ }
+
+ for (int i = 0; i < 16; i++) { // 128k + 16k + 48k = 192k
+ sb.append(mixed1k);
+ }
+ for (int i = 0; i < 48; i++) {
+ sb.append(alpha1k);
+ }
+
+ for (int i = 0; i < 16; i++) { // 192k + 16k + 48k = 256k
+ sb.append(alpha1k);
+ }
+ for (int i = 0; i < 48; i++) {
+ sb.append(space1k, 0, 512 - 10 * i);
+ sb.append(comma1k, 512 + 10 * i, comma1k.length());
+ }
+
+ return new IndexedLineReader(
+ "big_file", -1, new ByteArrayInputStream(sb.toString().getBytes(UTF_8)));
+ }
+
+ /** Output validation messages on the error console. */
+ private void printErrors() {
+ for (CommitValidationMessage message : scannerConfig.messages) {
+ System.err.printf("%s: %s\n", message.getType(), message.getMessage());
+ }
+ }
+
+ /** Output a usage message on the error console. */
+ private static void usage() {
+ System.err.printf(
+ "%s <plugin-name> <project.config>\n where:\n"
+ + " <plugin-name> is the name of the plugin. e.g. 'copyright'\n"
+ + " <project.config> is the path to the project.config file\n",
+ toolName);
+ System.exit(1);
+ }
+
+ /** Read the contents of a project.config file from {@code ilr}. */
+ private static String readProjectConfigFile(IndexedLineReader ilr) throws IOException {
+ StringBuilder sb = new StringBuilder();
+ CharBuffer cb = CharBuffer.wrap(BUFFER);
+ while (ilr.read(cb) >= 0) {
+ cb.flip();
+ sb.append(cb);
+ cb.clear();
+ }
+ String contents = sb.toString();
+ sb.setLength(0);
+ sb = null;
+ return contents;
+ }
+
+ /** Calculates the time signature for {@code elapsedMicros} and the current scanner pattern. */
+ @VisibleForTesting
+ String timeSignature(long elapsedMicros) {
+ StringBuilder sb = new StringBuilder();
+ sb.append(Long.toString(elapsedMicros));
+ sb.append("us");
+ sb.append(scannerConfig.patternSignature);
+
+ return Hashing.farmHashFingerprint64().hashBytes(sb.toString().getBytes(UTF_8)).toString();
+ }
+
+ /** Entry point for command-line tool to check for excessive backtracking. */
+ public static void main(String[] args) {
+ if (args.length != 2) {
+ usage();
+ }
+ String pluginName = args[0];
+ String fileName = args[1].trim();
+ try {
+ IndexedLineReader ilr =
+ new IndexedLineReader(
+ fileName, Paths.get(fileName).toFile().length(), new FileInputStream(fileName));
+ CheckConfig myConfig = new CheckConfig(pluginName, readProjectConfigFile(ilr));
+ checkProjectConfig(null, false, myConfig);
+ if (myConfig.scannerConfig.hasErrors()) {
+ myConfig.printErrors();
+ System.exit(1);
+ }
+ Preconditions.checkNotNull(myConfig.scannerConfig.scanner);
+
+ long elapsedMicros = myConfig.timeLargeFileInMicros();
+ Preconditions.checkArgument(elapsedMicros >= 0);
+
+ if (elapsedMicros > 1000000) { // longer than a second migth be a problem...
+ System.err.println(
+ "\n\nThis tool used your copyright plugin config against a large, hard-");
+ System.err.println("to-scan file.");
+ System.err.println(
+ "\nEven for large, difficult to scan files, it's best to keep scan times");
+ System.err.println("below 1 second, due to load on the server.\n");
+ System.err.println(CopyrightReviewApi.typicalBacktrackingCauses());
+ if (elapsedMicros > 60000000) { // minutes is way too long on any server...
+ System.err.printf(
+ "\nAt %ds, the scan took longer than %d minutes! Please change\n",
+ elapsedMicros / 1000000, elapsedMicros / 60000000);
+ System.err.println("whatever pattern causes the problem befure submitting.");
+ } else if (elapsedMicros > 10000000) { // tens of seconds is too long on any server...
+ System.err.printf(
+ "\nThe scan took longer than %d seconds! It's very likely this\n",
+ elapsedMicros / 1000000);
+ System.err.println(
+ "configuration will cause problems on your server. Please try to change");
+ System.err.println("whatever pattern causes the problem.");
+ } else if (elapsedMicros > 2000000) { // multiple seconds is likely a problem...
+ System.err.printf(
+ "\nThe scan took longer than %d seconds. It's possible this configuration\n",
+ 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("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.");
+ }
+ } else if (elapsedMicros > 1000) {
+ System.err.printf("\nScanned the test load in %dms.\n", elapsedMicros / 1000);
+ } else {
+ System.err.printf("\nScanned the test load in %d microseconds.\n", elapsedMicros);
+ }
+
+ String signature = myConfig.timeSignature(elapsedMicros);
+ System.out.println("\n\nCopy the line below into your commit message:");
+ System.out.printf("\nCopyright-check: %x.%s\n\n\n", elapsedMicros, signature);
+ System.exit(0);
+ } catch (IOException e) {
+ System.err.printf("Could not read %s\n%s\n", fileName, e.getMessage());
+ System.exit(1);
+ } catch (ConfigInvalidException e) {
+ System.err.printf("Could not parse %s\n%s\n", fileName, e.getMessage());
+ System.exit(1);
+ }
+ }
+}
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 c7b0e18..ac76aa4 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/copyright/CopyrightConfig.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/copyright/CopyrightConfig.java
@@ -14,12 +14,12 @@
package com.googlesource.gerrit.plugins.copyright;
-import static com.google.common.base.Preconditions.checkArgument;
-import static java.util.Objects.requireNonNull;
+import static java.nio.charset.StandardCharsets.UTF_8;
import com.google.common.annotations.VisibleForTesting;
-import com.google.common.base.Joiner;
import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableList;
+import com.google.common.flogger.FluentLogger;
import com.google.gerrit.extensions.annotations.PluginName;
import com.google.gerrit.extensions.api.changes.AddReviewerResult;
import com.google.gerrit.extensions.api.changes.ReviewResult;
@@ -27,72 +27,98 @@
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.Description;
+import com.google.gerrit.metrics.Description.Units;
import com.google.gerrit.metrics.MetricMaker;
-import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.metrics.Timer0;
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.CommitValidationListener;
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.Constants;
import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectLoader;
import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevTree;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.eclipse.jgit.treewalk.TreeWalk;
/** Listener to manage configuration for enforcing review of copyright declarations and licenses. */
@Singleton
-class CopyrightConfig {
+class CopyrightConfig
+ implements CommitValidationListener, RevisionCreatedListener, GitReferenceUpdatedListener {
+ private static final FluentLogger logger = FluentLogger.forEnclosingClass();
- private static final String DEFAULT_REVIEW_LABEL = "Copyright-Review";
+ private final long DEFAULT_MAX_ELAPSED_SECONDS = 8;
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;
+ private PluginConfig gerritConfig;
+ private CheckConfig checkConfig;
+
+ static AbstractModule module() {
+ return new AbstractModule() {
+ @Override
+ protected void configure() {
+ DynamicSet.bind(binder(), CommitValidationListener.class).to(CopyrightConfig.class);
+ DynamicSet.bind(binder(), RevisionCreatedListener.class).to(CopyrightConfig.class);
+ DynamicSet.bind(binder(), GitReferenceUpdatedListener.class).to(CopyrightConfig.class);
+ }
+ };
+ }
@Singleton
private static class Metrics {
+ final Timer0 readConfigTimer;
+ final Timer0 checkConfigTimer;
+ final Timer0 testConfigTimer;
+
@Inject
- Metrics(MetricMaker metricMaker) {}
+ Metrics(MetricMaker metricMaker) {
+ readConfigTimer =
+ metricMaker.newTimer(
+ "plugins/copyright/read_config_latency",
+ new Description("Time spent reading and parsing plugin configurations")
+ .setCumulative()
+ .setUnit(Units.MICROSECONDS));
+ checkConfigTimer =
+ metricMaker.newTimer(
+ "plugins/copyright/check_config_latency",
+ new Description("Time spent testing proposed plugin configurations")
+ .setCumulative()
+ .setUnit(Units.MICROSECONDS));
+ testConfigTimer =
+ metricMaker.newTimer(
+ "plugins/copyright/test_config_latency",
+ new Description("Time spent testing configurations against difficult file pattern")
+ .setCumulative()
+ .setUnit(Units.MICROSECONDS));
+ }
}
@Inject
@@ -102,121 +128,341 @@
@PluginName String pluginName,
GitRepositoryManager repoManager,
ProjectCache projectCache,
- ProjectConfig.Factory projectConfigFactory,
PluginConfigFactory pluginConfigFactory,
- CopyrightReviewApi reviewApi) throws IOException {
+ CopyrightReviewApi reviewApi)
+ throws IOException, ConfigInvalidException {
this.metrics = metrics;
this.allProjectsName = allProjectsName;
this.pluginName = pluginName;
this.repoManager = repoManager;
this.projectCache = projectCache;
- this.projectConfigFactory = projectConfigFactory;
this.pluginConfigFactory = pluginConfigFactory;
this.reviewApi = reviewApi;
+
+ long nanoStart = System.nanoTime();
+ try {
+ checkConfig = readConfig(projectCache.getAllProjects().getProject().getConfigRefState());
+ } finally {
+ long elapsedMicros = (System.nanoTime() - nanoStart) / 1000;
+ metrics.readConfigTimer.record(elapsedMicros, TimeUnit.MICROSECONDS);
+ }
}
- private CopyrightConfig(MetricMaker metricMaker, CopyrightReviewApi reviewApi) {
+ private CopyrightConfig(
+ MetricMaker metricMaker, CopyrightReviewApi reviewApi, String projectConfigContents)
+ throws ConfigInvalidException {
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();
+ checkConfig = new CheckConfig(pluginName, projectConfigContents);
}
@VisibleForTesting
- static CopyrightConfig createTestInstance(MetricMaker metricMaker, CopyrightReviewApi reviewApi) {
- return new CopyrightConfig(metricMaker, reviewApi);
+ static CopyrightConfig createTestInstance(
+ MetricMaker metricMaker, CopyrightReviewApi reviewApi, String projectConfigContents)
+ throws ConfigInvalidException {
+ return new CopyrightConfig(metricMaker, reviewApi, projectConfigContents);
}
- @VisibleForTesting
ScannerConfig getScannerConfig() {
- return scannerConfig;
+ return checkConfig.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;
- }
+ /** Listens for merges to /refs/meta/config on All-Projects to reload plugin configuration. */
+ @Override
+ public void onGitReferenceUpdated(GitReferenceUpdatedListener.Event event) {
+ if (!event.getRefName().equals(RefNames.REFS_CONFIG)) {
+ return;
}
- return false;
+ if (!event.getProjectName().equals(allProjectsName.get())) {
+ return;
+ }
+ long nanoStart = System.nanoTime();
+ try {
+ clearConfig();
+ checkConfig = readConfig(event.getNewObjectId());
+ } catch (IOException | ConfigInvalidException e) {
+ logger.atSevere().withCause(e).log("%s plugin unable to load configuration", pluginName);
+ checkConfig = null;
+ return;
+ } finally {
+ long elapsedMicros = (System.nanoTime() - nanoStart) / 1000;
+ metrics.readConfigTimer.record(elapsedMicros, TimeUnit.MICROSECONDS);
+ }
}
- /** 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;
+ /** Blocks upload of bad plugin configurations to /refs/meta/config on All-Projects. */
+ @Override
+ public List<CommitValidationMessage> onCommitReceived(CommitReceivedEvent event)
+ throws CommitValidationException {
+ if (!event.getBranchNameKey().get().equals(RefNames.REFS_CONFIG)) {
+ return Collections.emptyList();
}
+ if (!event.getProjectNameKey().equals(allProjectsName)) {
+ return Collections.emptyList();
+ }
+ long readStart = System.nanoTime();
+ long checkStart = -1;
+ long elapsedMicros = -1;
+ CheckConfig trialConfig = null;
+ try {
+ trialConfig = readConfig(event.commit.getName());
+ elapsedMicros = (System.nanoTime() - readStart) / 1000;
+ metrics.readConfigTimer.record(elapsedMicros, TimeUnit.MICROSECONDS);
+ if (Objects.equals(trialConfig.scannerConfig, checkConfig.scannerConfig)) {
+ return Collections.emptyList();
+ }
+ checkStart = System.nanoTime();
+ long maxElapsedSeconds =
+ gerritConfig == null
+ ? DEFAULT_MAX_ELAPSED_SECONDS
+ : gerritConfig.getLong(ScannerConfig.KEY_TIME_TEST_MAX, DEFAULT_MAX_ELAPSED_SECONDS);
+ if (maxElapsedSeconds > 0
+ && CheckConfig.hasScanner(trialConfig)
+ && !CheckConfig.scannersEqual(trialConfig, checkConfig)) {
+ String commitMessage = event.commit.getFullMessage();
+ ImmutableList<CopyrightReviewApi.CommitMessageFinding> findings =
+ trialConfig.checkCommitMessage(commitMessage);
+ if (CheckConfig.mustReportFindings(findings, maxElapsedSeconds)) {
+ throw reviewApi.getCommitMessageException(pluginName, findings, maxElapsedSeconds);
+ }
+ }
+ boolean pluginEnabled =
+ gerritConfig != null && gerritConfig.getBoolean(ScannerConfig.KEY_ENABLE, false);
+ CheckConfig.checkProjectConfig(reviewApi, pluginEnabled, trialConfig);
+ return trialConfig == null || trialConfig.scannerConfig == null
+ ? Collections.emptyList()
+ : trialConfig.scannerConfig.messages;
+ } catch (IOException e) {
+ logger.atSevere().withCause(e).log(
+ "failed to read new project.config for %s plugin", pluginName);
+ throw new CommitValidationException("failed to read new project.config", e);
+ } catch (ConfigInvalidException e) {
+ logger.atSevere().withCause(e).log("unable to parse %s plugin config", pluginName);
+ if (trialConfig != null && trialConfig.scannerConfig != null) {
+ trialConfig.scannerConfig.messages.add(ScannerConfig.errorMessage(e.getMessage()));
+ return trialConfig.scannerConfig.messages;
+ } else {
+ throw new CommitValidationException("unable to parse new project.config", e);
+ }
+ } finally {
+ if (elapsedMicros < 0) {
+ elapsedMicros = (System.nanoTime() - readStart) / 1000;
+ metrics.readConfigTimer.record(elapsedMicros, TimeUnit.MICROSECONDS);
+ } else if (checkStart >= 0) {
+ long elapsedNanos = System.nanoTime() - checkStart;
+ metrics.checkConfigTimer.record(elapsedNanos, TimeUnit.NANOSECONDS);
+ }
+ if (trialConfig != null
+ && trialConfig.scannerConfig != null
+ && trialConfig.scannerConfig.hasErrors()) {
+ StringBuilder sb = new StringBuilder();
+ sb.append("\nerror in ");
+ sb.append(pluginName);
+ sb.append(" plugin configuration");
+ trialConfig.scannerConfig.appendMessages(sb);
+ throw new CommitValidationException(sb.toString());
+ }
+ }
+ }
- @Override
- public boolean equals(Object other) {
- if (this == other) {
- return true;
+ /** Warns on review thread about suspect plugin configurations. */
+ @Override
+ public void onRevisionCreated(RevisionCreatedListener.Event event) {
+ String project = event.getChange().project;
+ String branch = event.getChange().branch;
+
+ if (!branch.equals(RefNames.REFS_CONFIG)) {
+ return;
+ }
+ if (!project.equals(allProjectsName.get())) {
+ return;
+ }
+ if (!event.getRevision().files.keySet().contains(ProjectConfig.PROJECT_CONFIG)) {
+ return;
+ }
+ // passed onCommitReceived so expect at worst only warnings here
+ long readStart = System.nanoTime();
+ long checkStart = -1;
+ long elapsedMicros = -1;
+ CheckConfig trialConfig = null;
+ try {
+ trialConfig = readConfig(event.getChange().currentRevision);
+ elapsedMicros = (System.nanoTime() - readStart) / 1000;
+ metrics.readConfigTimer.record(elapsedMicros, TimeUnit.MICROSECONDS);
+ if (Objects.equals(trialConfig, checkConfig.scannerConfig)) {
+ return;
}
- if (other == null) {
- return false;
+
+ checkStart = System.nanoTime();
+ if (CheckConfig.hasScanner(trialConfig)
+ && !CheckConfig.scannersEqual(trialConfig, checkConfig)) {
+ long maxElapsedSeconds =
+ gerritConfig == null
+ ? DEFAULT_MAX_ELAPSED_SECONDS
+ : gerritConfig.getLong(
+ ScannerConfig.KEY_TIME_TEST_MAX, DEFAULT_MAX_ELAPSED_SECONDS);
+ if (maxElapsedSeconds > 0) {
+ String commitMessage = event.getRevision().commitWithFooters;
+ ImmutableList<CopyrightReviewApi.CommitMessageFinding> findings =
+ trialConfig.checkCommitMessage(commitMessage);
+ ReviewResult result =
+ reviewApi.reportCommitMessageFindings(
+ pluginName,
+ allProjectsName.get(),
+ checkConfig == null ? null : checkConfig.scannerConfig,
+ trialConfig.scannerConfig,
+ event,
+ findings,
+ maxElapsedSeconds);
+ logReviewResultErrors(event, result);
+ }
}
- 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);
+ boolean pluginEnabled =
+ gerritConfig != null && gerritConfig.getBoolean(ScannerConfig.KEY_ENABLE, false);
+ CheckConfig.checkProjectConfig(reviewApi, pluginEnabled, trialConfig);
+ return;
+ } catch (RestApiException | ConfigInvalidException | IOException e) {
+ logger.atSevere().withCause(e).log("%s plugin unable to read new configuration", pluginName);
+ // throw IllegalStateException? RestApiException?
+ return;
+ } finally {
+ if (trialConfig != null
+ && trialConfig.scannerConfig != null
+ && !trialConfig.scannerConfig.hasErrors()) {
+ try {
+ ReviewResult result =
+ reviewApi.reportConfigMessages(
+ pluginName,
+ project,
+ ProjectConfig.PROJECT_CONFIG,
+ checkConfig.scannerConfig,
+ trialConfig.scannerConfig,
+ event);
+ logReviewResultErrors(event, result);
+ } catch (RestApiException e) {
+ logger.atSevere().withCause(e).log(
+ "%s plugin unable to read new configuration", pluginName);
+ // throw IllegalStateException? RestApiException?
+ return;
+ }
}
+ if (elapsedMicros < 0) {
+ elapsedMicros = (System.nanoTime() - readStart) / 1000;
+ metrics.readConfigTimer.record(elapsedMicros, TimeUnit.MICROSECONDS);
+ } else if (checkStart >= 0) {
+ long elapsedNanos = System.nanoTime() - checkStart;
+ metrics.checkConfigTimer.record(elapsedNanos, TimeUnit.NANOSECONDS);
+ }
+ }
+ }
+
+ /** Returns true if copyright validation enabled for {@code project}. */
+ boolean isProjectEnabled(ScannerConfig scannerConfig, String project) {
+ // scan all projects when missing
+ if (!scannerConfig.matchProjects.isEmpty()
+ && !ScannerConfig.matchesAny(project, scannerConfig.matchProjects)) {
+ // doesn't match == isn't checked
return false;
}
-
- @Override
- public int hashCode() {
- return Objects.hash(
- defaultEnable,
- messages,
- alwaysReviewPath,
- matchProjects,
- excludeProjects,
- thirdPartyAllowedProjects,
- reviewers,
- ccs,
- reviewLabel,
- scanner);
+ // exclude no projects when missing
+ if (!scannerConfig.excludeProjects.isEmpty()
+ && ScannerConfig.matchesAny(project, scannerConfig.excludeProjects)) {
+ // does match == isn't checked
+ return false;
}
+ ProjectState projectState;
+ try {
+ projectState = projectCache.checkedGet(new Project.NameKey(project));
+ } catch (IOException e) {
+ logger.atSevere().withCause(e).log("error getting project state of %s", project);
+ // throw IllegalStateException? RestApiException?
+ return scannerConfig.defaultEnable;
+ }
+ if (projectState == null) {
+ logger.atSevere().log("error getting project state of %s", project);
+ // throw IllegalStateException? RestApiException?
+ return scannerConfig.defaultEnable;
+ }
+ ProjectConfig projectConfig = projectState.getConfig();
+ if (projectConfig == null) {
+ logger.atWarning().log("error getting project config of %s", project);
+ // throw IllegalStateException? RestApiException? return?
+ return scannerConfig.defaultEnable;
+ }
+ PluginConfig pluginConfig = projectConfig.getPluginConfig(pluginName);
+ if (pluginConfig == null) {
+ // no plugin config section in project so use default
+ return scannerConfig.defaultEnable;
+ }
+ return pluginConfig.getBoolean(ScannerConfig.KEY_ENABLE, scannerConfig.defaultEnable);
+ }
- /** Returns true if {@code project} repository allows third-party code. */
- boolean isThirdPartyAllowed(String project) {
- return matchesAny(project, thirdPartyAllowedProjects);
+ /**
+ * Loads and compiles configured patterns from {@code ref/meta/All-Projects/project.config} and
+ * {@code gerrit.config}.
+ *
+ * @param projectConfigObjectId identifies the version of project.config to load and to compile
+ * @return the new scanner configuration to check
+ * @throws IOException if accessing the repository fails
+ */
+ private CheckConfig readConfig(String projectConfigObjectId)
+ throws IOException, ConfigInvalidException {
+ CheckConfig checkConfig = null;
+ // new All-Projects project.config not yet in cache -- read from repository
+ ObjectId id = ObjectId.fromString(projectConfigObjectId);
+ if (ObjectId.zeroId().equals(id)) {
+ return checkConfig;
+ }
+ try (Repository repo = repoManager.openRepository(allProjectsName)) {
+ checkConfig =
+ new CheckConfig(pluginName, readFileContents(repo, id, ProjectConfig.PROJECT_CONFIG));
+ }
+ gerritConfig = pluginConfigFactory.getFromGerritConfig(pluginName, true);
+ if (gerritConfig == null) {
+ // throw IllegalStateException? RestApiException?
+ checkConfig.scannerConfig.messages.add(
+ ScannerConfig.hintMessage(
+ "missing [plugin \"" + pluginName + "\"] section in gerrit.config"));
+ } else {
+ checkConfig.scannerConfig.defaultEnable =
+ gerritConfig.getBoolean(ScannerConfig.KEY_ENABLE, false);
+ }
+ return checkConfig;
+ }
+
+ /** Erases any prior configuration state. */
+ private void clearConfig() {
+ checkConfig = null;
+ }
+
+ private void logReviewResultErrors(RevisionCreatedListener.Event event, ReviewResult result) {
+ if (!Strings.isNullOrEmpty(result.error)) {
+ 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 (!Strings.isNullOrEmpty(arr.error)) {
+ logger.atSevere().log(
+ "%s plugin revision %s: error adding reviewer %s: %s",
+ pluginName, event.getChange().currentRevision, entry.getKey(), arr.error);
+ }
+ }
+ }
+
+ private String readFileContents(Repository repo, ObjectId objectId, String filename)
+ throws IOException {
+ RevWalk rw = new RevWalk(repo);
+ RevTree tree = rw.parseTree(objectId);
+ try (TreeWalk tw = TreeWalk.forPath(rw.getObjectReader(), filename, tree)) {
+ ObjectLoader loader = repo.open(tw.getObjectId(0), Constants.OBJ_BLOB);
+ return new String(loader.getCachedBytes(), UTF_8);
}
}
}
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 89c9c78..1463db3 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/copyright/CopyrightReviewApi.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/copyright/CopyrightReviewApi.java
@@ -17,12 +17,13 @@
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.THIRD_PARTY;
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.Strings;
import com.google.common.base.Throwables;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableMap;
@@ -53,19 +54,23 @@
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.CommitValidationException;
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.google.inject.Singleton;
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.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.TimeUnit;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
import javax.inject.Inject;
/** Utility to report revision findings on the review thread. */
@@ -156,6 +161,132 @@
}
/**
+ * Reports failure findings from or about the check_new_config.sh tool on the review thread.
+ *
+ * <p>If no output found in the commit message, directs the submitter to run the tool and copy its
+ * output into the commit message.
+ *
+ * <p>If the output was found for a different scanner pattern, directs the submitter to run again
+ * for the current commit.
+ *
+ * <p>Otherwise, the duration of the timing run must exceed the configured limit. Describes
+ * patterns known to cause problems and directs the submitter to change the pattern.
+ *
+ * @param pluginName as installed in gerrit
+ * @param project identifies the All-Projects config for recording metrics
+ * @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
+ * @param findings describes the line numbers, validity and timing run durations found
+ * @param maxElapsedSeconds the largest allows timing run duration in seconds
+ * @throws RestApiException if an error occurs updating the review thread
+ */
+ ReviewResult reportCommitMessageFindings(
+ String pluginName,
+ String project,
+ ScannerConfig oldConfig,
+ ScannerConfig newConfig,
+ RevisionCreatedListener.Event event,
+ ImmutableList<CommitMessageFinding> findings,
+ long maxElapsedSeconds)
+ throws RestApiException {
+ Preconditions.checkNotNull(newConfig);
+ Preconditions.checkNotNull(newConfig.scanner);
+ Preconditions.checkArgument(
+ oldConfig == null
+ || oldConfig.scanner == null
+ || !oldConfig.scanner.equals(newConfig.scanner));
+ Preconditions.checkArgument(
+ findings.size() != 1
+ || !findings.get(0).isValid());
+ Preconditions.checkArgument(maxElapsedSeconds > 0);
+
+ long startNanos = System.nanoTime();
+ metrics.reviewCount.increment();
+ metrics.reviewCountByProject.increment(project);
+
+ int fromAccountId =
+ oldConfig != null && oldConfig.fromAccountId > 0
+ ? oldConfig.fromAccountId
+ : newConfig.fromAccountId;
+ ChangeResource change = getChange(event, fromAccountId);
+ Map<String, List<CommentInfo>> priorReviewComments = getComments(change);
+ if (priorReviewComments == null) {
+ priorReviewComments = ImmutableMap.of();
+ }
+ HashSet<CommentInfo> priorComments = new HashSet<>();
+ priorComments.addAll(priorReviewComments.get("/COMMIT_MSG"));
+
+ ReviewInput ri = new ReviewInput();
+ ri.message(getCommitMessageMessage(pluginName, findings, maxElapsedSeconds));
+ ImmutableList<CommentInput> comments =
+ ImmutableList.copyOf(
+ getCommitMessageComments(pluginName, findings, maxElapsedSeconds).stream()
+ .filter(ci -> !priorComments.contains(ci))
+ .toArray(i -> new CommentInput[i]));
+ if (!comments.isEmpty()) {
+ ri.comments = ImmutableMap.of("/COMMIT_MSG", comments);
+ }
+ String label = "Code-Review";
+ if (!Strings.isNullOrEmpty(oldConfig.reviewLabel)) {
+ label = oldConfig.reviewLabel;
+ } else if (!Strings.isNullOrEmpty(newConfig.reviewLabel)) {
+ label = newConfig.reviewLabel;
+ }
+ int vote = (findings.size() != 1 || !findings.get(0).isValid()) ? -2 : 2;
+ if (vote == 2) {
+ long elapsedMicros = findings.get(0).elapsedMicros;
+ if (elapsedMicros > maxElapsedSeconds * 1000000) {
+ vote = -2;
+ } else if (elapsedMicros > 2000000) {
+ vote = 1;
+ }
+ }
+ ri.label(label, vote);
+ return review(change, ri);
+ }
+
+ /**
+ * Returns a {@link com.google.gerrit.server.git.validators.CommitValidationException} describing
+ * failure findings from or about the check_new_config.sh tool.
+ *
+ * <p>If no output found in the commit message, directs the submitter to run the tool and copy its
+ * output into the commit message.
+ *
+ * <p>If the output was found for a different scanner pattern, directs the submitter to run again
+ * for the current commit.
+ *
+ * <p>Otherwise, the duration of the timing run must exceed the configured limit. Describes
+ * patterns known to cause problems and directs the submitter to change the pattern.
+ *
+ * @param pluginName as installed in gerrit
+ * @param findings describes the line numbers, validity and timing run durations found
+ * @param maxElapsedSeconds the largest allows timing run duration in seconds
+ */
+ public CommitValidationException getCommitMessageException(
+ String pluginName, ImmutableList<CommitMessageFinding> findings, long maxElapsedSeconds) {
+ Preconditions.checkArgument(
+ findings.size() != 1
+ || !findings.get(0).isValid()
+ || findings.get(0).elapsedMicros > maxElapsedSeconds * 1000000);
+ StringBuilder sb = new StringBuilder();
+ sb.append(getCommitMessageMessage(pluginName, findings, maxElapsedSeconds));
+ sb.append("\n\n");
+ ImmutableList<CommentInput> comments =
+ getCommitMessageComments(pluginName, findings, maxElapsedSeconds);
+ for (CommentInput ci : comments) {
+ if (ci.line != 0) {
+ sb.append("commit message line ");
+ sb.append(Integer.toString(ci.range.startLine));
+ sb.append(":\n");
+ }
+ sb.append(ci.message);
+ sb.append("\n");
+ }
+ return new CommitValidationException(sb.toString());
+ }
+
+ /**
* Reports validation findings for a proposed new plugin configuration to the review thread for
* the newly pushed revision.
*
@@ -171,9 +302,10 @@
String pluginName,
String project,
String path,
- CopyrightConfig.ScannerConfig oldConfig,
- CopyrightConfig.ScannerConfig newConfig,
- RevisionCreatedListener.Event event) throws RestApiException {
+ ScannerConfig oldConfig,
+ ScannerConfig newConfig,
+ RevisionCreatedListener.Event event)
+ throws RestApiException {
Preconditions.checkNotNull(newConfig);
Preconditions.checkNotNull(newConfig.messages);
Preconditions.checkArgument(!newConfig.messages.isEmpty());
@@ -186,13 +318,12 @@
int fromAccountId =
oldConfig != null && oldConfig.fromAccountId > 0
? oldConfig.fromAccountId
- : newConfig.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());
+ ReviewInput ri = new ReviewInput().message(message.toString());
Map<String, List<CommentInfo>> priorComments = getComments(change);
if (priorComments == null) {
@@ -240,9 +371,10 @@
*/
ReviewResult reportScanFindings(
String project,
- CopyrightConfig.ScannerConfig scannerConfig,
+ ScannerConfig scannerConfig,
RevisionCreatedListener.Event event,
- Map<String, ImmutableList<Match>> findings) throws RestApiException {
+ Map<String, ImmutableList<Match>> findings)
+ throws RestApiException {
long startNanos = System.nanoTime();
metrics.reviewCount.increment();
metrics.reviewCountByProject.increment(project);
@@ -266,9 +398,10 @@
}
}
ChangeResource change = getChange(event, scannerConfig.fromAccountId);
- ReviewInput ri = new ReviewInput()
- .message("Copyright scan")
- .label(scannerConfig.reviewLabel, reviewRequired ? -1 : +2);
+ ReviewInput ri =
+ new ReviewInput()
+ .message("Copyright scan")
+ .label(scannerConfig.reviewLabel, reviewRequired ? -1 : +2);
if (reviewRequired) {
ri = addReviewers(ri, scannerConfig.ccs, ReviewerState.CC);
@@ -301,9 +434,11 @@
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]));
+ newComments =
+ ImmutableList.copyOf(
+ newComments.stream()
+ .filter(ci -> !containsComment(prior, ci))
+ .toArray(i -> new CommentInput[i]));
if (newComments.isEmpty()) {
continue;
}
@@ -328,18 +463,179 @@
/**
* Returns the {@link com.google.gerrit.server.CurrentUser} seeming to send the review comments.
*
- * Impersonates {@code fromAccountId} if configured by {@code fromAccountId =} in plugin
+ * <p>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);
+ 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.
+ * Returns 1 of 3 review messages depending on the check_new_config.sh tool output.
+ *
+ * @param pluginName as installed in gerrit
+ * @param findings describes the line numbers, validity and timing run durations found
+ */
+ private String getCommitMessageMessage(
+ String pluginName, ImmutableList<CommitMessageFinding> findings, long maxElapsedSeconds) {
+ StringBuilder sb = new StringBuilder();
+ if (findings.isEmpty()) {
+ sb.append(pluginName);
+ sb.append(" plugin: match patterns have changed; please run check_new_config tool");
+ } else if (findings.size() == 1 && findings.get(0).isValid()) {
+ long elapsedMicros = findings.get(0).elapsedMicros;
+ if (elapsedMicros > maxElapsedSeconds * 1000000) {
+ sb.append("check_new_config: problem pattern detected");
+ } else if (elapsedMicros > 2000000) {
+ sb.append("check_new_config: possible problem pattern detected");
+ } else if (elapsedMicros > 1000000) {
+ sb.append("check_new_config: possibly okay");
+ } else {
+ sb.append("check_new_config: okay");
+ }
+ } else {
+ sb.append("check_new_config: results for wrong commit; please run again for current");
+ }
+ return sb.toString();
+ }
+
+ /**
+ * Returns one or more of 3 review comment versions based on the check_new_config.sh tool output.
+ *
+ * @param pluginName as installed in gerrit
+ * @param findings describes the line numbers, validity and timing run durations found
+ * @param maxElapsedSeconds the largest allows timing run duration in seconds
+ */
+ private ImmutableList<CommentInput> getCommitMessageComments(
+ String pluginName, ImmutableList<CommitMessageFinding> findings, long maxElapsedSeconds) {
+ ImmutableList.Builder<CommentInput> comments = ImmutableList.builder();
+ StringBuilder sb = new StringBuilder();
+ CommentInput ci = new CommentInput();
+ if (findings.isEmpty()) {
+ sb.append("While most patterns are fine, some patterns can force your gerrit server\n");
+ sb.append("to work too hard. To protect your server, there is a tool that can\n");
+ sb.append("detect these patterns before the configuration gets submitted.\n\n");
+ sb.append("Please use git to download the tool from:\n");
+ sb.append("https://gerrit.googlesource.com/plugins/copyright/+/refs/heads/master\n\n");
+ sb.append("After downloading, run tools/check_new_config.sh (requires bazel):\n");
+ sb.append("<path>/tools/check_new_config.sh '");
+ sb.append(pluginName);
+ sb.append("' '<path>/project.config'\n");
+ sb.append("and copy it's output to your commit message.\n\n");
+ sb.append("e.g. if your local All-Projects is at workspace/All-Projects and if you\n");
+ sb.append("downloaded plugins/copyright to workspace/copyright, you might run:\n");
+ sb.append("../copyright/tools/check_new_config.sh '");
+ sb.append(pluginName);
+ sb.append("' project.config\n");
+ sb.append("from the workspace/All-Projects directory.\n");
+ ci.line = 0;
+ ci.unresolved = true;
+ ci.message = sb.toString();
+ comments.add(ci);
+ } else if (findings.size() == 1 && findings.get(0).isValid()) {
+ CommitMessageFinding finding = findings.get(0);
+ if (finding.elapsedMicros > maxElapsedSeconds * 1000000) {
+ sb.append("Scanning the test file took longer than ");
+ sb.append(Long.toString(maxElapsedSeconds));
+ sb.append(" seconds.");
+ if (finding.elapsedMicros - (maxElapsedSeconds * 1000000) > 1000000) {
+ sb.append(" (");
+ sb.append(Long.toString(finding.elapsedMicros / 1000000));
+ sb.append(" seconds)");
+ }
+ sb.append("\n\nThis is much longer than usual even on a slower, modern computer.\n\n");
+ sb.append("The result suggests a pattern that causes excessive backtracking.\n");
+ sb.append(typicalBacktrackingCauses());
+ sb.append("\nPlease fix any problematic patterns and try again.\n");
+ } else if (finding.elapsedMicros > 2000000) {
+ sb.append("Scanning the test file took longer than 2 seconda. (");
+ if (finding.elapsedMicros > 3000000) {
+ sb.append(Long.toString(finding.elapsedMicros / 1000000));
+ sb.append(" seconds)");
+ } else {
+ sb.append(Long.toString(finding.elapsedMicros / 1000));
+ sb.append(" ms)");
+ }
+ sb.append("\n\nThe result suggests a pattern that might cause excessive backtracking.\n");
+ sb.append(typicalBacktrackingCauses());
+ sb.append("\nPlease try to fix any problematic patterns before proceeding.\n");
+ } else if (finding.elapsedMicros > 1000000) {
+ sb.append("Scanning the test file took just longer than a second. (");
+ sb.append(Long.toString(finding.elapsedMicros / 1000));
+ sb.append("ms)");
+ sb.append("\n\nThe result is a little longer than ideal.\n\n");
+ sb.append(typicalBacktrackingCauses());
+ sb.append("\n\nCompare with the current config, and if this config is signigicantly\n");
+ sb.append("slower, consider changing whatever pattern causes the problem.\n");
+ } else if (finding.elapsedMicros > 1000) {
+ sb.append("Scanning the test file took ");
+ sb.append(Long.toString(finding.elapsedMicros / 1000));
+ sb.append("ms.");
+ } else {
+ sb.append("Scanning the test file took ");
+ sb.append(Long.toString(finding.elapsedMicros));
+ sb.append(" microsends.");
+ }
+ ci.line = finding.endLine;
+ ci.range = new CommentInput.Range();
+ ci.range.startLine = finding.startLine;
+ ci.range.endLine = finding.endLine;
+ ci.range.startCharacter = finding.startCol;
+ ci.range.endCharacter = finding.endCol;
+ ci.unresolved = finding.elapsedMicros > 1000000;
+ ci.message = sb.toString();
+ comments.add(ci);
+ } else {
+ for (CommitMessageFinding finding : findings) {
+ sb.setLength(0);
+ sb.append("'");
+ sb.append(finding.text.trim());
+ sb.append("'\nis not a result for the patterns in the current revision");
+ ci.line = finding.endLine;
+ ci.range = new CommentInput.Range();
+ ci.range.startLine = finding.startLine;
+ ci.range.endLine = finding.endLine;
+ ci.range.startCharacter = finding.startCol;
+ ci.range.endCharacter = finding.endCol;
+ ci.unresolved = true;
+ ci.message = sb.toString();
+ comments.add(ci);
+ ci = new CommentInput();
+ }
+ }
+ return comments.build();
+ }
+
+ public static String typicalBacktrackingCauses() {
+ StringBuilder sb = new StringBuilder();
+ sb.append("Typical causes of excessive backtracking include:\n");
+ sb.append(" 1. unbounded repetitions of wildcards or\n");
+ sb.append(" 2. zero-length look-ahead/look-behind patterns\n\n");
+ sb.append("Wildcards:\n");
+ sb.append(" The scanner automatically handles .* and .+ patterns, but it's\n");
+ sb.append(" possible to accidentally compose equivalents or near-equivalents:\n");
+ sb.append(" e.g. (?:[a]|[^a])* or [\\\\s\\\\p{N}\\\\p{L}\\\\p{P}]+ match nearly everything\n");
+ sb.append(" If your new pattern contains something similar, consider using .* for\n");
+ sb.append(" automatic handling instead, or use a smaller character class.\n\n");
+ sb.append("Unbounded repetitions:\n");
+ sb.append(" If your new pattern uses * or + for unlimited repetitions, consider\n");
+ sb.append(" using a more limited repetition like {0,10} or {1,50} that is long\n");
+ sb.append(" enough to match what you need but short enough to scan quickly.\n\n");
+ sb.append("Zero-length look-ahead or look-behind:\n");
+ sb.append(" Patterns like (?!word), (?=word), (?<!word). (?<=word) etc. can cause\n");
+ sb.append(" excessive backtracking too. Sometimes, it is faster to match a little\n");
+ sb.append(" more than needed and use an excludePattern to eliminate unwanted hits.\n");
+ sb.append(" e.g. forbiddenPattern = owner some pattern \\p{L}*\n");
+ sb.append(" excludePattern = some pattern word\n");
+ return sb.toString();
+ }
+
+ /**
+ * 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
@@ -355,7 +651,8 @@
} catch (Exception e) {
Throwables.throwIfUnchecked(e);
throw e instanceof RestApiException
- ? (RestApiException) e : new RestApiException("Cannot load change", e);
+ ? (RestApiException) e
+ : new RestApiException("Cannot load change", e);
}
}
@@ -383,7 +680,8 @@
} catch (Exception e) {
Throwables.throwIfUnchecked(e);
throw e instanceof RestApiException
- ? (RestApiException) e : new RestApiException("Cannot list comments", e);
+ ? (RestApiException) e
+ : new RestApiException("Cannot list comments", e);
}
}
@@ -403,7 +701,8 @@
} catch (Exception e) {
Throwables.throwIfUnchecked(e);
throw e instanceof RestApiException
- ? (RestApiException) e : new RestApiException("Cannot post review", e);
+ ? (RestApiException) e
+ : new RestApiException("Cannot post review", e);
}
}
@@ -427,13 +726,13 @@
* 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 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 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
*/
@@ -476,9 +775,9 @@
* 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 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
@@ -503,9 +802,10 @@
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);
+ 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;
@@ -541,9 +841,9 @@
}
/**
- * Calculates and returns the overall
- * {@link com.googlesource.gerrit.plugins.copyright.lib.CopyrightScanner.PartyType} for the
- * copyright scanner findings in {@code matches}.
+ * 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) {
@@ -564,4 +864,83 @@
}
return pt;
}
+
+ /**
+ * Found {@code main} output in the commit message.
+ *
+ * <p>Each finding identifies the position in the commit message, the validity for the current
+ * scanner pattern, and the large file scan duration if valid.
+ */
+ public static class CommitMessageFinding {
+ private static final Pattern NL = Pattern.compile("\n", Pattern.MULTILINE | Pattern.DOTALL);
+
+ /** The character offset into the commit message where the finding starts. */
+ public final int start;
+ /** The character offset into the commit message where the finding ends. */
+ public final int end;
+ /** The found text apparently matching {@code main} output. */
+ public final String text;
+ /** How long in microseconds it took to scan a large file, or -1 if scan with other pattern. */
+ public final long elapsedMicros;
+
+ /** The line number of the start of the finding in the commit message. */
+ public final int startLine;
+ /** The column (0-based) of the start of the finding in the commit message. */
+ public final int startCol;
+ /** The line number of the end of the finding in the commit message. */
+ public final int endLine;
+ /** The column (0-based) of the end of the finding in the commit message. */
+ public final int endCol;
+
+ /** Returns true when {@code elapsedMicros} reflects the current scanner pattern. */
+ public boolean isValid() {
+ return elapsedMicros >= 0;
+ }
+
+ /** A finding for the current scanner pattern with relevant {@code elapsedMicros}. */
+ CommitMessageFinding(String commitMsg, String text, String elapsedMicros, int start, int end) {
+ this.start = start;
+ this.end = end;
+ this.text = text;
+ this.elapsedMicros = Long.parseLong(elapsedMicros, 16);
+
+ Matcher m = NL.matcher(commitMsg);
+ int line = 1;
+ int lineStart = 0;
+ int startLine = 0;
+ int startCol = -1;
+ int endLine = 0;
+ int endCol = -1;
+ while (m.find()) {
+ if (m.start() > start) {
+ startLine = line;
+ startCol = start - lineStart;
+ }
+ if (m.start() > end) {
+ endLine = line;
+ endCol = end - lineStart;
+ break;
+ }
+ line++;
+ lineStart = m.end();
+ }
+ if (startLine == 0) {
+ startLine = line;
+ startCol = start - lineStart;
+ }
+ if (endLine == 0) {
+ endLine = line;
+ endCol = end - lineStart;
+ }
+ this.startLine = startLine;
+ this.startCol = startCol;
+ this.endLine = endLine;
+ this.endCol = endCol;
+ }
+
+ /** A finding for a different scanner pattern -- {@code elapsedMicros} not relevant. */
+ CommitMessageFinding(String commitMsg, String text, int start, int end) {
+ this(commitMsg, text, "-1", start, end);
+ }
+ }
}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/copyright/ScannerConfig.java b/src/main/java/com/googlesource/gerrit/plugins/copyright/ScannerConfig.java
new file mode 100644
index 0000000..815b28c
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/copyright/ScannerConfig.java
@@ -0,0 +1,377 @@
+// 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 java.util.Objects.requireNonNull;
+
+import com.google.common.base.Strings;
+import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.server.config.PluginConfig;
+import com.google.gerrit.server.git.validators.CommitValidationMessage;
+import com.google.gerrit.server.git.validators.ValidationMessage;
+import com.google.gerrit.server.project.ProjectConfig;
+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 java.util.ArrayList;
+import java.util.Collection;
+import java.util.LinkedHashSet;
+import java.util.Objects;
+import java.util.function.Consumer;
+import java.util.regex.Pattern;
+import java.util.regex.PatternSyntaxException;
+
+/** Configuration state for {@link CopyrightValidator}. */
+class ScannerConfig {
+ private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+ public static final String KEY_ENABLE = "enable";
+
+ static final String KEY_TIME_TEST_MAX = "timeTestMax";
+ static final String DEFAULT_REVIEW_LABEL = "Copyright-Review";
+ static final String KEY_REVIEWER = "reviewer";
+ static final String KEY_CC = "cc";
+ static final String KEY_FROM = "fromAccountId";
+ static final String KEY_REVIEW_LABEL = "reviewLabel";
+ static final String KEY_EXCLUDE = "exclude";
+ static final String KEY_FIRST_PARTY = "firstParty";
+ static final String KEY_THIRD_PARTY = "thirdParty";
+ static final String KEY_FORBIDDEN = "forbidden";
+ static final String KEY_EXCLUDE_PATTERN = "excludePattern";
+ static final String KEY_FIRST_PARTY_PATTERN = "firstPartyPattern";
+ static final String KEY_THIRD_PARTY_PATTERN = "thirdPartyPattern";
+ static final String KEY_FORBIDDEN_PATTERN = "forbiddenPattern";
+ static final String KEY_ALWAYS_REVIEW_PATH = "alwaysReviewPath";
+ static final String KEY_MATCH_PROJECTS = "matchProjects";
+ static final String KEY_EXCLUDE_PROJECTS = "excludeProjects";
+ static final String KEY_THIRD_PARTY_ALLOWED_PROJECTS = "thirdPartyAllowedProjects";
+ private static final String OWNER = "owner ";
+ private static final String LICENSE = "license ";
+
+ String pluginName;
+ CopyrightScanner scanner;
+ String patternSignature;
+ 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(String pluginName) {
+ this.pluginName = pluginName;
+ 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);
+ }
+
+ /** Formats {@code message} into a message about the plugin configuration. */
+ String pluginMessage(String message) {
+ return " in " + ProjectConfig.PROJECT_CONFIG + "\n[plugin \"" + pluginName + "\"]\n" + message;
+ }
+
+ /** Formats {@code message} into a message about the {@code key = value} line of the config. */
+ String pluginKeyValueMessage(String key, String value, String message) {
+ StringBuilder sb = new StringBuilder();
+ sb.append(" ");
+ sb.append(key);
+ sb.append(" = ");
+ sb.append(value.trim());
+ sb.append("\n");
+ sb.append(" ".substring(0, key.length() + 5));
+ sb.append("^\n"); // ^ aligned under start of value in message
+ sb.append(message);
+ return pluginMessage(sb.toString());
+ }
+
+ static CommitValidationMessage errorMessage(String message) {
+ return new CommitValidationMessage(message, ValidationMessage.Type.ERROR);
+ }
+
+ static CommitValidationMessage warningMessage(String message) {
+ return new CommitValidationMessage(message, ValidationMessage.Type.WARNING);
+ }
+
+ static CommitValidationMessage hintMessage(String message) {
+ return new CommitValidationMessage(message, ValidationMessage.Type.HINT);
+ }
+
+ /** Formats {@code message} into a message about a required {@code key} not found in config. */
+ String pluginKeyRequired(String key, String message) {
+ return pluginMessage("\nno \"" + key + " =\" key was found.\n\n" + message);
+ }
+
+ /**
+ * Adjusts {@code scannerConfig} state per {@code cfg}.
+ *
+ * @param builder accumulates the scanner pattern rules used for constructing the scanner
+ * @param scannerConfig records the configuration parameter values
+ * @param cfg is the source of the configuration
+ */
+ void readConfigFile(PluginConfig cfg) {
+ CopyrightPatterns.RuleSet.Builder builder = CopyrightPatterns.RuleSet.builder();
+ // can be disabled in All-Projects and then enabled project-by-project
+ defaultEnable = cfg.getBoolean(KEY_ENABLE, defaultEnable);
+ fromAccountId = cfg.getInt(KEY_FROM, 0);
+ addStringList(cfg, reviewers, KEY_REVIEWER, "reviewer email address");
+ addStringList(cfg, ccs, KEY_CC, "CC email address");
+ String key = cfg.getString(KEY_REVIEW_LABEL);
+ if (!Strings.isNullOrEmpty(key)) {
+ reviewLabel = key;
+ }
+ addPatternList(cfg, alwaysReviewPath, KEY_ALWAYS_REVIEW_PATH, "path pattern");
+ addPatternList(cfg, matchProjects, KEY_MATCH_PROJECTS, "project pattern");
+ addPatternList(cfg, excludeProjects, KEY_EXCLUDE_PROJECTS, "project pattern");
+ addPatternList(
+ cfg, thirdPartyAllowedProjects, KEY_THIRD_PARTY_ALLOWED_PROJECTS, "project pattern");
+ // exclusions
+ addRule(
+ cfg,
+ rule -> {
+ builder.exclude(rule);
+ },
+ KEY_EXCLUDE);
+ addRule(
+ cfg,
+ rule -> {
+ builder.excludePattern(rule);
+ },
+ KEY_EXCLUDE_PATTERN);
+ // first-party
+ addRule(
+ cfg,
+ rule -> {
+ builder.addFirstParty(rule);
+ },
+ KEY_FIRST_PARTY);
+ addRulePattern(
+ cfg,
+ pattern -> {
+ builder.addFirstPartyOwner(pattern);
+ },
+ pattern -> {
+ builder.addFirstPartyLicense(pattern);
+ },
+ KEY_FIRST_PARTY_PATTERN);
+ // third-party
+ addRule(
+ cfg,
+ rule -> {
+ builder.addThirdParty(rule);
+ },
+ KEY_THIRD_PARTY);
+ addRulePattern(
+ cfg,
+ pattern -> {
+ builder.addThirdPartyOwner(pattern);
+ },
+ pattern -> {
+ builder.addThirdPartyLicense(pattern);
+ },
+ KEY_THIRD_PARTY_PATTERN);
+ // forbidden
+ addRule(
+ cfg,
+ rule -> {
+ builder.addForbidden(rule);
+ },
+ KEY_FORBIDDEN);
+ addRulePattern(
+ cfg,
+ pattern -> {
+ builder.addForbiddenOwner(pattern);
+ },
+ pattern -> {
+ builder.addForbiddenLicense(pattern);
+ },
+ KEY_FORBIDDEN_PATTERN);
+ if (hasErrors()) {
+ // don't try to compile scanner if errors in configuration
+ return;
+ }
+ CopyrightPatterns.RuleSet rules = builder.build();
+ patternSignature = rules.signature();
+ scanner =
+ new CopyrightScanner(
+ rules.firstPartyLicenses,
+ rules.thirdPartyLicenses,
+ rules.forbiddenLicenses,
+ rules.firstPartyOwners,
+ rules.thirdPartyOwners,
+ rules.forbiddenOwners,
+ rules.excludePatterns);
+ }
+
+ /** Returns true if {@code project} repository allows third-party code. */
+ boolean isThirdPartyAllowed(String project) {
+ return matchesAny(project, thirdPartyAllowedProjects);
+ }
+
+ /** Returns true if {@code fullPath} matches pattern always requiring review. e.g. PATENT */
+ boolean isAlwaysReviewPath(String fullPath) {
+ return matchesAny(fullPath, alwaysReviewPath);
+ }
+
+ /** Returns true if the configuration triggers any error messages. */
+ boolean hasErrors() {
+ return messages.stream().anyMatch(m -> m.getType().equals(ValidationMessage.Type.ERROR));
+ }
+
+ /** Formats and appends config validation messages to {@code sb}. */
+ void appendMessages(StringBuilder sb) {
+ for (CommitValidationMessage msg : messages) {
+ sb.append("\n\n");
+ sb.append(msg.getType().toString());
+ sb.append(" ");
+ sb.append(msg.getMessage());
+ }
+ }
+
+ /** Returns true if any pattern in {@code regexes} found in {@code text}. */
+ static boolean matchesAny(String text, Collection<Pattern> regexes) {
+ requireNonNull(regexes);
+ for (Pattern pattern : regexes) {
+ if (pattern.matcher(text).find()) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /** Looks up {@code key} in {@code cfg} adding values to {@code dest} as rule names. */
+ private void addRule(PluginConfig cfg, Consumer<String> dest, String key) {
+ for (String rule : cfg.getStringList(key)) {
+ rule = Strings.nullToEmpty(rule).trim();
+ if (rule.isEmpty()) {
+ messages.add(
+ errorMessage(pluginKeyValueMessage(key, rule, "missing license or owner name")));
+ continue;
+ }
+ try {
+ dest.accept(rule);
+ } catch (UnknownPatternName e) {
+ messages.add(errorMessage(pluginKeyValueMessage(key, rule, e.getMessage())));
+ }
+ }
+ }
+
+ /**
+ * Looks up {@code key} in {@code cfg} adding {@code owner} patterns to {@code ownerDest} and
+ * {@code license} patterns to {@code licenseDest}.
+ */
+ private void addRulePattern(
+ PluginConfig cfg, Consumer<String> ownerDest, Consumer<String> licenseDest, String key) {
+ for (String cfgValue : cfg.getStringList(key)) {
+ cfgValue = Strings.nullToEmpty(cfgValue).trim();
+ if (cfgValue.isEmpty()) {
+ messages.add(
+ errorMessage(pluginKeyValueMessage(key, cfgValue, "missing owner or license pattern")));
+ continue;
+ }
+ if (cfgValue.toLowerCase().startsWith(OWNER)) {
+ String pattern = cfgValue.substring(OWNER.length()).trim();
+ ownerDest.accept(pattern);
+ } else if (cfgValue.toLowerCase().startsWith(LICENSE)) {
+ String pattern = cfgValue.substring(LICENSE.length()).trim();
+ licenseDest.accept(pattern);
+ } else {
+ messages.add(
+ errorMessage(
+ pluginKeyValueMessage(
+ key, cfgValue, "missing 'owner' or 'license' keyword in '" + cfgValue + "'")));
+ }
+ }
+ }
+
+ /** Looks up {@code key} in {@code cfg} adding values to {@code dest} as strings. */
+ private void addStringList(
+ PluginConfig cfg, Collection<String> dest, String key, String shortDesc) {
+ for (String s : cfg.getStringList(key)) {
+ s = Strings.nullToEmpty(s).trim();
+ if (s.isEmpty()) {
+ messages.add(errorMessage(pluginKeyValueMessage(key, s, "missing " + shortDesc)));
+ continue;
+ }
+ dest.add(s);
+ }
+ }
+
+ /** Looks up {@code key} in {@code cfg} adding values to {@code dest} as regex patterns. */
+ private void addPatternList(
+ PluginConfig cfg, Collection<Pattern> dest, String key, String shortDesc) {
+ for (String pattern : cfg.getStringList(key)) {
+ pattern = Strings.nullToEmpty(pattern).trim();
+ if (pattern.isEmpty()) {
+ messages.add(errorMessage(pluginKeyValueMessage(key, pattern, "missing " + shortDesc)));
+ continue;
+ }
+ try {
+ dest.add(Pattern.compile(pattern));
+ } catch (PatternSyntaxException e) {
+ messages.add(errorMessage(pluginKeyValueMessage(key, pattern, e.getMessage())));
+ }
+ }
+ }
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/copyright/lib/CopyrightPatterns.java b/src/main/java/com/googlesource/gerrit/plugins/copyright/lib/CopyrightPatterns.java
index 390ffe2..060dffc 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/copyright/lib/CopyrightPatterns.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/copyright/lib/CopyrightPatterns.java
@@ -14,11 +14,15 @@
package com.googlesource.gerrit.plugins.copyright.lib;
+import static java.nio.charset.StandardCharsets.UTF_8;
+
import com.google.common.annotations.VisibleForTesting;
import com.google.common.base.Joiner;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableMap;
+import com.google.common.hash.Hashing;
import java.util.NoSuchElementException;
+import org.apache.commons.lang.StringUtils;
/** Constants declaring match patterns for common copyright licenses and owners. */
public abstract class CopyrightPatterns {
@@ -375,6 +379,25 @@
return new Builder();
}
+ public String signature() {
+ StringBuilder sb = new StringBuilder();
+ sb.append("1pl:\n");
+ sb.append(Joiner.on("\n").join(firstPartyLicenses));
+ sb.append("\n1po:\n");
+ sb.append(Joiner.on("\n").join(firstPartyOwners));
+ sb.append("\n3pl:\n");
+ sb.append(Joiner.on("\n").join(thirdPartyLicenses));
+ sb.append("\n3po:\n");
+ sb.append(Joiner.on("\n").join(thirdPartyOwners));
+ sb.append("\n!!l:\n");
+ sb.append(Joiner.on("\n").join(forbiddenLicenses));
+ sb.append("\n!!o:\n");
+ sb.append(Joiner.on("\n").join(forbiddenOwners));
+ sb.append("\nxx:\n");
+ sb.append(Joiner.on("\n").join(excludePatterns));
+ return Hashing.farmHashFingerprint64().hashBytes(sb.toString().getBytes(UTF_8)).toString();
+ }
+
/** Implements the Builder pattern for CopyrightPatterns.RuleSet. */
public static class Builder {
private final ImmutableList.Builder<String> firstPartyLicenses =
@@ -408,7 +431,7 @@
public Builder addFirstParty(String ruleName) {
Rule pattern = lookup.get(ruleName);
if (pattern == null) {
- throw new UnknownPatternName(ruleName);
+ throw unknownPatternName(ruleName);
}
if (pattern.licenses != null) {
firstPartyLicenses.addAll(pattern.licenses);
@@ -438,7 +461,7 @@
public Builder addThirdParty(String ruleName) {
Rule pattern = lookup.get(ruleName);
if (pattern == null) {
- throw new UnknownPatternName(ruleName);
+ throw unknownPatternName(ruleName);
}
if (pattern.licenses != null) {
thirdPartyLicenses.addAll(pattern.licenses);
@@ -468,7 +491,7 @@
public Builder addForbidden(String ruleName) {
Rule pattern = lookup.get(ruleName);
if (pattern == null) {
- throw new UnknownPatternName(ruleName);
+ throw unknownPatternName(ruleName);
}
if (pattern.licenses != null) {
forbiddenLicenses.addAll(pattern.licenses);
@@ -498,7 +521,7 @@
public Builder exclude(String ruleName) {
Rule pattern = lookup.get(ruleName);
if (pattern == null) {
- throw new UnknownPatternName(ruleName);
+ throw unknownPatternName(ruleName);
}
if (pattern.licenses != null) {
excludePatterns.addAll(pattern.licenses);
@@ -535,6 +558,41 @@
this.forbiddenOwners = forbiddenOwners;
this.excludePatterns = excludePatterns;
}
+
+ private static UnknownPatternName unknownPatternName(String ruleName) {
+ int minDist = -1;
+ for (String key : lookup.keySet()) {
+ int dist = StringUtils.getLevenshteinDistance(key, ruleName);
+ if (minDist < 0 || dist < minDist) {
+ minDist = dist;
+ }
+ }
+ ImmutableList.Builder<String> closeMatches = ImmutableList.builder();
+ int numClose = 0;
+ if (minDist < 3) {
+ for (String key : lookup.keySet()) {
+ int dist = StringUtils.getLevenshteinDistance(key, ruleName);
+ if (dist == minDist) {
+ closeMatches.add(key);
+ numClose++;
+ }
+ }
+ }
+ String matches = Joiner.on(", ").join(numClose > 0 ? closeMatches.build() : lookup.keySet());
+ int lastIndex = matches.lastIndexOf(", ");
+ if (lastIndex > 0) {
+ matches = matches.substring(0, lastIndex + 2) + "or " + matches.substring(lastIndex + 2);
+ }
+ String message =
+ "Unknown license or copyright owner name: "
+ + ruleName
+ + "\n\n"
+ + (numClose > 0
+ ? "Did you mean " + matches + "?"
+ : "Known names are: " + matches + ".");
+
+ return new UnknownPatternName(message);
+ }
}
/** Initialize a pattern consisting of only a list of owner patterns. */
@@ -584,12 +642,8 @@
/** Thrown when requesting a pattern by a name that does not appear among the known patterns. */
public static class UnknownPatternName extends NoSuchElementException {
- UnknownPatternName(String ruleName) {
- super(
- "Unknown pattern name: "
- + ruleName
- + "\nKnown pattern names include: "
- + Joiner.on(", ").join(lookup.keySet()));
+ UnknownPatternName(String message) {
+ super(message);
}
}
}
diff --git a/src/test/java/com/googlesource/gerrit/plugins/copyright/CopyrightConfigIT.java b/src/test/java/com/googlesource/gerrit/plugins/copyright/CopyrightConfigIT.java
new file mode 100644
index 0000000..fb5bcd6
--- /dev/null
+++ b/src/test/java/com/googlesource/gerrit/plugins/copyright/CopyrightConfigIT.java
@@ -0,0 +1,398 @@
+// 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_FIRST_PARTY;
+import static com.googlesource.gerrit.plugins.copyright.ScannerConfig.KEY_FIRST_PARTY_PATTERN;
+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.ScannerConfig.KEY_REVIEW_LABEL;
+import static com.googlesource.gerrit.plugins.copyright.ScannerConfig.KEY_THIRD_PARTY_PATTERN;
+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.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 CopyrightConfigIT extends LightweightPluginDaemonTest {
+ @Test
+ @GerritConfigs({
+ @GerritConfig(name = "plugin.copyright.enable", value = "true"),
+ @GerritConfig(name = "plugin.copyright.timeTestMax", value = "0")
+ })
+ public void testCopyrightConfig_pushGoodConfig() throws Exception {
+ PushOneCommit.Result actual = testConfig.push(pushFactory);
+ actual.assertOkStatus();
+ assertThat(actual.getChange().publishedComments()).isEmpty();
+ }
+
+ @Test
+ @GerritConfigs({
+ @GerritConfig(name = "plugin.copyright.enable", value = "true"),
+ @GerritConfig(name = "plugin.copyright.timeTestMax", value = "0")
+ })
+ public void testCopyrightConfig_pushWithoutSender() throws Exception {
+ testConfig.updatePlugin(
+ cfg -> {
+ cfg.setString(KEY_FROM, "");
+ });
+
+ testConfig.push(pushFactory).assertErrorStatus("no \"" + KEY_FROM + " =\" key was found");
+ }
+
+ @Test
+ @GerritConfigs({
+ @GerritConfig(name = "plugin.copyright.enable", value = "false"),
+ @GerritConfig(name = "plugin.copyright.timeTestMax", value = "0")
+ })
+ public void testCopyrightConfig_pushDisabledWithoutSender() throws Exception {
+ testConfig.updatePlugin(
+ cfg -> {
+ cfg.setString(KEY_FROM, "");
+ });
+
+ PushOneCommit.Result actual = testConfig.push(pushFactory);
+ String expected = "no \"" + KEY_FROM + " =\" key was found";
+ actual.assertMessage(expected);
+ actual.assertOkStatus();
+ assertThat(actual.getChange().publishedComments())
+ .comparingElementsUsing(warningContains())
+ .containsExactly(expected);
+ }
+
+ @Test
+ @GerritConfigs({
+ @GerritConfig(name = "plugin.copyright.enable", value = "true"),
+ @GerritConfig(name = "plugin.copyright.timeTestMax", value = "0")
+ })
+ public void testCopyrightConfig_pushWithSenderNoName() throws Exception {
+ TestAccount anonymous =
+ accountCreator.create(
+ "copyright-scan", "", "", "Non-Interactive Users", expertGroup.getName());
+ testConfig.updatePlugin(
+ cfg -> {
+ cfg.setInt(KEY_FROM, anonymous.id().get());
+ });
+
+ PushOneCommit.Result actual = testConfig.push(pushFactory);
+ String expected = "has no full name";
+ actual.assertMessage(expected);
+ actual.assertOkStatus();
+ assertThat(actual.getChange().publishedComments())
+ .comparingElementsUsing(warningContains())
+ .containsExactly(expected);
+ }
+
+ @Test
+ @GerritConfig(name = "plugin.copyright.timeTestMax", value = "0")
+ public void testCopyrightConfig_pushWithNonNumericSender() throws Exception {
+ testConfig.updatePlugin(
+ cfg -> {
+ cfg.setString(KEY_FROM, "some random string");
+ });
+
+ testConfig.push(pushFactory).assertErrorStatus();
+ }
+
+ @Test
+ @GerritConfig(name = "plugin.copyright.timeTestMax", value = "0")
+ public void testCopyrightConfig_pushWithoutReviewers() throws Exception {
+ testConfig.updatePlugin(
+ cfg -> {
+ cfg.setStringList(KEY_REVIEWER, ImmutableList.of());
+ });
+
+ PushOneCommit.Result actual = testConfig.push(pushFactory);
+ String expected = "no \"" + KEY_REVIEWER + " =\" key was found";
+ actual.assertMessage(expected);
+ actual.assertOkStatus();
+ assertThat(actual.getChange().publishedComments())
+ .comparingElementsUsing(warningContains())
+ .containsExactly(expected);
+ }
+
+ @Test
+ @GerritConfigs({
+ @GerritConfig(name = "plugin.copyright.enable", value = "true"),
+ @GerritConfig(name = "plugin.copyright.timeTestMax", value = "0")
+ })
+ public void testCopyrightConfig_pushWithoutLabelConfig() throws Exception {
+ testConfig.removeLabel("Copyright-Review");
+
+ testConfig
+ .push(pushFactory)
+ .assertErrorStatus("no [label \"Copyright-Review\"] section configured");
+ }
+
+ @Test
+ @GerritConfigs({
+ @GerritConfig(name = "plugin.copyright.enable", value = "true"),
+ @GerritConfig(name = "plugin.copyright.timeTestMax", value = "0")
+ })
+ public void testCopyrightConfig_pushWithoutVoters() throws Exception {
+ testConfig.removeVoters(RefNames.REFS_HEADS + "*", "Copyright-Review");
+
+ testConfig.push(pushFactory).assertErrorStatus("no configured approvers");
+ }
+
+ @Test
+ @GerritConfigs({
+ @GerritConfig(name = "plugin.copyright.enable", value = "true"),
+ @GerritConfig(name = "plugin.copyright.timeTestMax", value = "0")
+ })
+ public void testCopyrightConfig_pushWithoutConfigVoters() throws Exception {
+ testConfig.removeVoters(RefNames.REFS_CONFIG, "Copyright-Review");
+
+ testConfig.push(pushFactory).assertErrorStatus("no configured approvers");
+ }
+
+ @Test
+ @GerritConfigs({
+ @GerritConfig(name = "plugin.copyright.enable", value = "true"),
+ @GerritConfig(name = "plugin.copyright.timeTestMax", value = "0")
+ })
+ public void testCopyrightConfig_pushWithoutReviewLabel() throws Exception {
+ testConfig.updatePlugin(
+ cfg -> {
+ cfg.setString(KEY_REVIEW_LABEL, " ");
+ });
+
+ testConfig
+ .push(pushFactory)
+ .assertErrorStatus("no \"" + KEY_REVIEW_LABEL + " =\" key was found");
+ }
+
+ @Test
+ @GerritConfigs({
+ @GerritConfig(name = "plugin.copyright.enable", value = "false"),
+ @GerritConfig(name = "plugin.copyright.timeTestMax", value = "0")
+ })
+ public void testCopyrightConfig_pushDisabledWithoutReviewLabel() throws Exception {
+ testConfig.updatePlugin(
+ cfg -> {
+ cfg.setString(KEY_REVIEW_LABEL, " ");
+ });
+
+ PushOneCommit.Result actual = testConfig.push(pushFactory);
+ String expected = "no \"" + KEY_REVIEW_LABEL + " =\" key was found";
+ actual.assertMessage(expected);
+ actual.assertOkStatus();
+ assertThat(actual.getChange().publishedComments())
+ .comparingElementsUsing(warningContains())
+ .containsExactly(expected);
+ }
+
+ @Test
+ @GerritConfig(name = "plugin.copyright.timeTestMax", value = "0")
+ public void testCopyrightConfig_pushPatternWithCapture() throws Exception {
+ testConfig.updatePlugin(
+ cfg -> {
+ cfg.setString(KEY_FIRST_PARTY_PATTERN, "owner (capture group)");
+ });
+
+ testConfig.push(pushFactory).assertErrorStatus();
+ }
+
+ @Test
+ @GerritConfig(name = "plugin.copyright.timeTestMax", value = "0")
+ public void testCopyrightConfig_pushPatternWithNonCapture() throws Exception {
+ testConfig.updatePlugin(
+ cfg -> {
+ cfg.setString(KEY_THIRD_PARTY_PATTERN, "owner (?:non-capture group)");
+ });
+
+ PushOneCommit.Result actual = testConfig.push(pushFactory);
+ actual.assertOkStatus();
+ assertThat(actual.getChange().publishedComments()).isEmpty();
+ }
+
+ @Test
+ @GerritConfig(name = "plugin.copyright.timeTestMax", value = "0")
+ public void testCopyrightConfig_pushPatternWithOpenNonCapture() throws Exception {
+ testConfig.updatePlugin(
+ cfg -> {
+ cfg.setString(KEY_THIRD_PARTY_PATTERN, "owner (?:non-capture group");
+ });
+
+ testConfig.push(pushFactory).assertErrorStatus();
+ }
+
+ @Test
+ @GerritConfig(name = "plugin.copyright.timeTestMax", value = "0")
+ public void testCopyrightConfig_pushPatternWithUnknownRuleName() throws Exception {
+ testConfig.updatePlugin(
+ cfg -> {
+ cfg.setString(KEY_FIRST_PARTY, "not a valid rule name");
+ });
+
+ testConfig.push(pushFactory).assertErrorStatus("Unknown license or copyright owner name");
+ }
+
+ @Test
+ @GerritConfig(name = "plugin.copyright.timeTestMax", value = "0")
+ public void testCopyrightConfig_pushPatternWithOpenCharacterClass() throws Exception {
+ testConfig.updatePlugin(
+ cfg -> {
+ cfg.setString(KEY_FIRST_PARTY_PATTERN, "license non-terminated [");
+ });
+
+ testConfig.push(pushFactory).assertErrorStatus();
+ }
+
+ @Test
+ @GerritConfig(name = "plugin.copyright.timeTestMax", value = "1")
+ public void testCopyrightConfig_pushPatternWithoutTimeSignature() throws Exception {
+ testConfig.push(pushFactory).assertErrorStatus("please run check_new_config tool");
+ }
+
+ @Test
+ @GerritConfig(name = "plugin.copyright.timeTestMax", value = "1")
+ public void testCopyrightConfig_pushPatternWithWrongTimeSignature() throws Exception {
+ testConfig.commitMessage("Copyright-check: b0bb4.3141596634344624");
+ testConfig.push(pushFactory).assertErrorStatus("results for wrong commit");
+ }
+
+ @Test
+ @GerritConfig(name = "plugin.copyright.timeTestMax", value = "8")
+ public void testCopyrightConfig_pushPatternWithRightTimeSignature() throws Exception {
+ testConfig.commitMessage("Copyright-check: b0bb4.40bd43852e4bcf12");
+ testConfig.push(pushFactory).assertOkStatus();
+ }
+
+ @Test
+ @GerritConfig(name = "plugin.copyright.timeTestMax", value = "8")
+ public void testCopyrightConfig_pushPatternWithTooLongTimeSignature() throws Exception {
+ testConfig.commitMessage("Copyright-check: 7a1201.677dcd085b30b4e1");
+ testConfig.push(pushFactory).assertErrorStatus("took longer than");
+ }
+
+ private static int nextId = 123;
+
+ @Inject @ServerInitiated private Provider<GroupsUpdate> groupsUpdateProvider;
+
+ private TestAccount sender;
+ private TestAccount reviewer;
+ private TestAccount observer;
+ private InternalGroup botGroup;
+ private InternalGroup expertGroup;
+ private TestConfig testConfig;
+
+ @Before
+ public void setUp() throws Exception {
+ botGroup = testGroup("Non-Interactive Users");
+ expertGroup = testGroup("Copyright Experts");
+ sender =
+ 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");
+ testRepo = getTestRepo(allProjects);
+ 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, sender.id().get());
+ });
+ }
+
+ 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 static Correspondence<Comment, String> warningContains() {
+ return new Correspondence<Comment, String>() {
+ @Override
+ public boolean compare(Comment actual, String expected) {
+ return actual.message.startsWith("WARNING ") && actual.message.contains(expected);
+ }
+
+ @Override
+ public String toString() {
+ return "matches regex";
+ }
+ };
+ }
+}
diff --git a/src/test/java/com/googlesource/gerrit/plugins/copyright/CopyrightReviewApiTest.java b/src/test/java/com/googlesource/gerrit/plugins/copyright/CopyrightReviewApiTest.java
index 4c0b4b6..2c1cda7 100644
--- a/src/test/java/com/googlesource/gerrit/plugins/copyright/CopyrightReviewApiTest.java
+++ b/src/test/java/com/googlesource/gerrit/plugins/copyright/CopyrightReviewApiTest.java
@@ -18,15 +18,14 @@
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.THIRD_PARTY;
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;
@@ -57,16 +56,17 @@
@Before
public void setUp() throws Exception {
- reviewApi = new CopyrightReviewApi(
- null,
- () -> pluginUser,
- () -> currentUser,
- identifiedUserFactory,
- null,
- null,
- null,
- null,
- null);
+ reviewApi =
+ new CopyrightReviewApi(
+ null,
+ () -> pluginUser,
+ () -> currentUser,
+ identifiedUserFactory,
+ null,
+ null,
+ null,
+ null,
+ null);
}
@Test
@@ -97,9 +97,7 @@
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");
+ assertThat(ri.reviewers).comparingElementsUsing(addressedTo()).containsExactly("CC:someone");
}
@Test
@@ -114,8 +112,9 @@
@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("someone", "someone else"), ReviewerState.REVIEWER);
ri = reviewApi.addReviewers(ri, ImmutableList.of("another", "and another"), ReviewerState.CC);
assertThat(ri.reviewers)
.comparingElementsUsing(addressedTo())
@@ -164,10 +163,8 @@
@Test
public void testContainsComment_multipleDoContain() throws Exception {
- ImmutableList<CommentInput> comments = ImmutableList.of(
- CI("one", 1, 2),
- CI("two", 806, 808),
- CI("three", 3, 14));
+ 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();
@@ -175,183 +172,157 @@
@Test
public void testContainsComment_multipleDoNotContain() throws Exception {
- ImmutableList<CommentInput> comments = ImmutableList.of(
- CI("one", 1, 2),
- CI("two", 806, 808),
- CI("three", 3, 14));
+ 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));
+ 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));
+ 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));
+ 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));
+ 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));
+ 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));
+ 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);
+ 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);
+ 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);
+ 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);
+ 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);
+ 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);
+ 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);
+ reviewApi.partyType(
+ ImmutableList.of(licUnknown(2), lic3p(3), auth3p(4), lic1p(5), auth1p(6))))
+ .isEqualTo(UNKNOWN);
}
private static class FakePluginUser extends PluginUser {
@@ -365,6 +336,7 @@
public Object getCacheKey() {
return "31415966";
}
+
@Override
public GroupMembership getEffectiveGroups() {
return null;
@@ -373,32 +345,32 @@
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 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";
- }
- };
+ @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 boolean compare(AddReviewerInput actual, String expected) {
+ return expected.equals(actual.state().toString() + ":" + actual.reviewer);
+ }
- @Override
- public String toString() {
- return "addressed to";
- }
- };
+ @Override
+ public String toString() {
+ return "addressed to";
+ }
+ };
}
/** Comment input {@code text} from {@code start} line to {@code end} line. */
diff --git a/src/test/java/com/googlesource/gerrit/plugins/copyright/TestConfig.java b/src/test/java/com/googlesource/gerrit/plugins/copyright/TestConfig.java
new file mode 100644
index 0000000..a8fc2c5
--- /dev/null
+++ b/src/test/java/com/googlesource/gerrit/plugins/copyright/TestConfig.java
@@ -0,0 +1,273 @@
+// 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_ENABLE;
+import static com.googlesource.gerrit.plugins.copyright.ScannerConfig.KEY_EXCLUDE;
+import static com.googlesource.gerrit.plugins.copyright.ScannerConfig.KEY_FIRST_PARTY;
+import static com.googlesource.gerrit.plugins.copyright.ScannerConfig.KEY_FORBIDDEN;
+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 java.nio.charset.StandardCharsets.UTF_8;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.TestAccount;
+import com.google.gerrit.common.data.GroupReference;
+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.group.InternalGroup;
+import com.google.gerrit.server.project.GroupList;
+import com.google.gerrit.server.project.ProjectConfig;
+import java.util.Arrays;
+import java.util.function.Consumer;
+import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
+import org.eclipse.jgit.junit.TestRepository;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.ObjectLoader;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.revwalk.RevBlob;
+import org.eclipse.jgit.revwalk.RevObject;
+import org.eclipse.jgit.revwalk.RevWalk;
+
+/** Test helper for copyright plugin All-Projects configurations. */
+class TestConfig {
+ static final String LOCAL_BRANCH = "config";
+
+ private static final String ACCESS = "access";
+ private static final String LABEL = "label";
+ private static final String PLUGIN = "plugin";
+
+ private Project.NameKey project;
+ private String pluginName;
+ private TestAccount owner;
+ private TestRepository<InMemoryRepository> testRepo;
+ private Config configProject;
+ private Config configPlugin;
+ private PluginConfig pluginConfig;
+ private GroupList groupList;
+ private String commitMsg;
+
+ TestConfig(
+ Project.NameKey project,
+ String pluginName,
+ TestAccount owner,
+ TestRepository<InMemoryRepository> testRepo)
+ throws Exception {
+ this.project = project;
+ this.pluginName = pluginName;
+ this.owner = owner;
+ this.testRepo = testRepo;
+ readConfigProject();
+ extractPlugin();
+ this.commitMsg = "This is a commit.";
+ }
+
+ static final Consumer<PluginConfig> BASE_CONFIG =
+ pCfg -> {
+ pCfg.setString(KEY_REVIEW_LABEL, "Copyright-Review");
+ pCfg.setStringList(KEY_EXCLUDE, ImmutableList.of("EXAMPLES"));
+ pCfg.setStringList(
+ KEY_FIRST_PARTY, ImmutableList.of("APACHE2", "ANDROID", "GOOGLE", "EXAMPLES"));
+ pCfg.setStringList(
+ KEY_THIRD_PARTY, ImmutableList.of("BSD", "MIT", "EPL", "GPL2", "GPL3", "PSFL"));
+ pCfg.setStringList(
+ KEY_FORBIDDEN,
+ ImmutableList.of(
+ "AGPL",
+ "NOT_A_CONTRIBUTION",
+ "WTFPL",
+ "CC_BY_NC",
+ "NON_COMMERCIAL",
+ "COMMONS_CLAUSE"));
+ pCfg.setStringList(
+ KEY_FORBIDDEN_PATTERN,
+ ImmutableList.of("license .*(?:Previously|formerly) licen[cs]ed under.*"));
+ };
+
+ static final Consumer<PluginConfig> ENABLE_CONFIG =
+ pCfg -> {
+ pCfg.setBoolean(KEY_ENABLE, true);
+ };
+ static final Consumer<PluginConfig> DISABLE_CONFIG =
+ pCfg -> {
+ pCfg.setBoolean(KEY_ENABLE, false);
+ };
+
+ /** Adds {@code groups} to the groups file. */
+ void addGroups(InternalGroup... groups) throws Exception {
+ if (groups == null) {
+ return;
+ }
+ for (InternalGroup group : groups) {
+ groupList.put(
+ group.getGroupUUID(), new GroupReference(group.getGroupUUID(), group.getNameKey().get()));
+ }
+ }
+
+ /**
+ * Make a copy of an existing label configuration {@code fromLabel} under a different name, {@code
+ * toLabel}.
+ *
+ * <p>Useful shortcut for configuring a Copyright-Review label based on the default Code-Review
+ * label.
+ */
+ void copyLabel(String fromLabel, String toLabel) throws Exception {
+ for (String name : configProject.getNames(LABEL, fromLabel)) {
+ configProject.setStringList(
+ LABEL, toLabel, name, Arrays.asList(configProject.getStringList(LABEL, fromLabel, name)));
+ }
+ for (String sub : configProject.getSubsections(ACCESS)) {
+ for (String name : configProject.getNames(ACCESS, sub)) {
+ if (!name.endsWith("-" + fromLabel)) {
+ continue;
+ }
+ String[] values = configProject.getStringList(ACCESS, sub, name);
+ if (values == null || values.length == 0) {
+ continue;
+ }
+ configProject.setStringList(
+ ACCESS,
+ sub,
+ name.substring(0, name.length() - fromLabel.length()) + toLabel,
+ Arrays.asList(values));
+ }
+ }
+ }
+
+ /** Creates a commit for the current configuration and pushes the commit to trigger validation. */
+ PushOneCommit.Result push(PushOneCommit.Factory pushFactory) throws Exception {
+ savePlugin();
+ String subject = "Change All-Projects project.config\n\n" + commitMsg;
+ PersonIdent author = owner.newIdent();
+ PushOneCommit pushCommit =
+ pushFactory.create(
+ author,
+ testRepo,
+ subject,
+ ImmutableMap.<String, String>of(
+ ProjectConfig.PROJECT_CONFIG,
+ configProject.toText(),
+ GroupList.FILE_NAME,
+ groupList.asText()));
+ return pushCommit.to("refs/for/" + RefNames.REFS_CONFIG);
+ }
+
+ /** Adds {@code message} to the /COMMIT_MSG text for the push. */
+ void commitMessage(String message) throws Exception {
+ commitMsg = commitMsg + message;
+ }
+
+ /** Deletes the label section and voter configurations for {@code label}. */
+ void removeLabel(String label) throws Exception {
+ for (String sub : configProject.getSubsections(ACCESS)) {
+ configProject.setStringList(ACCESS, sub, "label-" + label, ImmutableList.of());
+ configProject.setStringList(ACCESS, sub, "labelAs-" + label, ImmutableList.of());
+ }
+ configProject.unsetSection(LABEL, label);
+ }
+
+ /** Deletes the voter configurations for {@code label} from the access section for {@code ref}. */
+ void removeVoters(String ref, String label) throws Exception {
+ configProject.setStringList(ACCESS, ref, "label-" + label, ImmutableList.of());
+ }
+
+ /**
+ * Replaces the voter configuration for {@code label} in the access section for {@code ref} with
+ * {@code voters}.
+ */
+ void setVoters(String ref, String label, Voter... voters) throws Exception {
+ ImmutableList.Builder<String> builder = ImmutableList.builder();
+ for (Voter v : voters) {
+ builder.add(vString(v.minVote) + ".." + vString(v.maxVote) + " group " + v.groupName);
+ }
+ configProject.setStringList(ACCESS, ref, "label-" + label, builder.build());
+ }
+
+ /** Apply {@code updates} to {@code this.pluginConfig}. */
+ void updatePlugin(Consumer<PluginConfig>... updates) throws Exception {
+ for (Consumer<PluginConfig> update : updates) {
+ update.accept(pluginConfig);
+ }
+ }
+
+ /** Extracts the plugin section from the project config and creates a PluginConfig view of it. */
+ private void extractPlugin() throws Exception {
+ configPlugin = new Config();
+ for (String name : configProject.getNames(PLUGIN, pluginName)) {
+ configPlugin.setStringList(
+ PLUGIN,
+ pluginName,
+ name,
+ Arrays.asList(configProject.getStringList(PLUGIN, pluginName, name)));
+ }
+ pluginConfig = new PluginConfig(pluginName, configPlugin);
+ }
+
+ /** Reads the project.config and groups files from {@code this.testRepo}. */
+ private void readConfigProject() throws Exception {
+ Ref ref = testRepo.getRepository().exactRef(LOCAL_BRANCH);
+ configProject = new Config();
+ configProject.fromText(readFileContents(ref, ProjectConfig.PROJECT_CONFIG));
+ groupList = GroupList.parse(project, readFileContents(ref, GroupList.FILE_NAME), e -> {});
+ }
+
+ /** Reads the contents of {@code ref}/{@code path} from {@code this.restRepo}. */
+ private String readFileContents(Ref ref, String path) throws Exception {
+ RevWalk rw = testRepo.getRevWalk();
+ RevObject obj = testRepo.get(rw.parseTree(ref.getObjectId()), path);
+ assertThat(obj).isInstanceOf(RevBlob.class);
+ ObjectLoader loader = rw.getObjectReader().open(obj);
+ return new String(loader.getCachedBytes(), UTF_8);
+ }
+
+ /**
+ * Replaces the plugin section in {@code this.configProject} with the contents of {@code
+ * this.configPlugin}.
+ */
+ private void savePlugin() throws Exception {
+ configProject.unsetSection(PLUGIN, pluginName);
+ for (String name : configPlugin.getNames(PLUGIN, pluginName)) {
+ configProject.setStringList(
+ PLUGIN,
+ pluginName,
+ name,
+ Arrays.asList(configPlugin.getStringList(PLUGIN, pluginName, name)));
+ }
+ }
+
+ /** Formats integers with an explicit sign for both positive and negative values. */
+ private String vString(int vote) throws Exception {
+ return (vote > 0 ? "+" : "") + Integer.toString(vote);
+ }
+
+ /** A group name and allowed vote range. */
+ static class Voter {
+ String groupName;
+ int minVote;
+ int maxVote;
+
+ Voter(String groupName, int minVote, int maxVote) {
+ this.groupName = groupName;
+ this.minVote = minVote;
+ this.maxVote = maxVote;
+ }
+ }
+}
diff --git a/tools/check_new_config.sh b/tools/check_new_config.sh
new file mode 100755
index 0000000..01cb7a4
--- /dev/null
+++ b/tools/check_new_config.sh
@@ -0,0 +1,78 @@
+#!/bin/sh
+
+# 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.
+
+readonly me=$(basename $0)
+readonly root=$(realpath $(dirname $0)/..)
+readonly build_flags="-c opt"
+
+function usage() {
+ echo
+ echo "Usage:"
+ echo
+ echo "\$ ${me} <plugin-name> <project.config>"
+ echo
+ echo "where:"
+ echo " <plugin-name> is the name of the copyright scanner plugin"
+ echo " <project.config> is the full pathname of the project.config file you want to push"
+ echo
+ if test $# -eq 2; then
+ if $(basename "${2}") = "project.config"; then
+ echo "${2} is not a regular file."
+ echo
+ else
+ echo "The project.config full path must end with project.config -- not $(basename $2)"
+ ehco
+ fi
+ fi
+}
+
+if test $# -ne 2; then
+ usage >&2
+ exit 1
+fi
+
+if test $(basename "${2}") != "project.config" -o ! -f "${2}"; then
+ usage >&2
+ exit 1
+fi
+
+readonly build_out=$(
+ cd "${root}" >&2
+ bazel build ${build_flags} :check_new_config >&2 2>/dev/null
+ echo -n $? " "
+ echo $(bazel info ${build_flags} bazel-bin 2>/dev/null)
+)
+rc=$(echo ${build_out} | cut -d\ -f1)
+if test $rc -ne 0; then
+ (
+ cd "${root}" >&2
+ bazel build ${build_flags} :check_new_config >&2
+ )
+ echo >&2
+ echo "Could not build check_new_config binary." >&2
+ echo >&2
+ exit 1
+fi
+
+readonly bazel_bin=$(echo ${build_out} | cut -d\ -f2-)
+
+"${bazel_bin}/check_new_config" "${1}" "${2}"
+rc=$?
+if test ${rc} -ne 0; then
+ echo >&2
+ echo "Please correct the errors above and run this tool again." >&2
+fi
+exit ${rc}