blob: 5f3509985e1b72b0bfe9f11b4c3cddfc20f814b6 [file] [log] [blame]
// Copyright (C) 2020 The Android Open Source Project
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package com.google.gerrit.plugins.codeowners.backend.findowners;
import static com.google.common.base.Preconditions.checkState;
import static com.google.common.collect.ImmutableList.toImmutableList;
import static com.google.common.collect.ImmutableSet.toImmutableSet;
import static com.google.common.collect.ImmutableSortedSet.toImmutableSortedSet;
import static java.util.Comparator.naturalOrder;
import static java.util.Objects.requireNonNull;
import static java.util.stream.Collectors.joining;
import com.google.common.annotations.VisibleForTesting;
import com.google.common.base.Joiner;
import com.google.common.base.Splitter;
import com.google.common.base.Strings;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableSet;
import com.google.common.collect.ListMultimap;
import com.google.common.collect.MultimapBuilder;
import com.google.common.collect.SortedSetMultimap;
import com.google.common.collect.TreeMultimap;
import com.google.gerrit.entities.Project;
import com.google.gerrit.plugins.codeowners.backend.CodeOwnerAnnotation;
import com.google.gerrit.plugins.codeowners.backend.CodeOwnerConfig;
import com.google.gerrit.plugins.codeowners.backend.CodeOwnerConfigImportMode;
import com.google.gerrit.plugins.codeowners.backend.CodeOwnerConfigParseException;
import com.google.gerrit.plugins.codeowners.backend.CodeOwnerConfigParser;
import com.google.gerrit.plugins.codeowners.backend.CodeOwnerConfigReference;
import com.google.gerrit.plugins.codeowners.backend.CodeOwnerReference;
import com.google.gerrit.plugins.codeowners.backend.CodeOwnerSet;
import com.google.gerrit.server.git.ValidationError;
import com.google.inject.Singleton;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.SortedSet;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Stream;
import org.eclipse.jgit.lib.ObjectId;
/**
* Parser and formatter for the syntax that is used to store {@link CodeOwnerConfig}s in {@code
* OWNERS} files as they are used by the {@code find-owners} plugin.
*
* <p>The syntax is described at in the {@code find-owners} plugin documentation at:
* https://gerrit.googlesource.com/plugins/find-owners/+/HEAD/src/main/resources/Documentation/syntax.md
*
* <p>Comment lines are silently ignored.
*
* <p>Invalid lines cause the parsing to fail and trigger a {@link CodeOwnerConfigParseException}.
*
* <p>Comments can appear as separate lines and as appendix for email lines (e.g. using
* 'foo.bar@example.com # Foo Bar' would be a valid email line).
*
* <p>Most of the code in this class was copied from the {@code
* com.googlesource.gerrit.plugins.findowners.Parser} class from the {@code find-owners} plugin. The
* original parsing code is used to be as backwards-compatible as possible and to avoid spending
* time on reimplementing a parser for a deprecated syntax. We have only done a minimal amount of
* adaption so that the parser produces a {@link CodeOwnerConfig} as result, instead of the
* abstraction that is used in the {@code find-owners} plugin.
*/
@Singleton
@VisibleForTesting
public class FindOwnersCodeOwnerConfigParser implements CodeOwnerConfigParser {
// Artifical owner token for "set noparent" when used in per-file.
private static final String TOK_SET_NOPARENT = "set noparent";
/**
* Any Unicode linebreak sequence, is equivalent to {@code
* \u000D\u000A|[\u000A\u000B\u000C\u000D\u0085\u2028\u2029]}.
*/
private static final String LINEBREAK_MATCHER = "\\R";
@Override
public CodeOwnerConfig parse(
ObjectId revision, CodeOwnerConfig.Key codeOwnerConfigKey, String codeOwnerConfigAsString)
throws CodeOwnerConfigParseException {
requireNonNull(revision, "revision");
requireNonNull(codeOwnerConfigKey, "codeOwnerConfigKey");
Parser parser = new Parser();
CodeOwnerConfig codeOwnerConfig =
parser.parse(revision, codeOwnerConfigKey, Strings.nullToEmpty(codeOwnerConfigAsString));
if (!parser.getValidationErrors().isEmpty()) {
throw new CodeOwnerConfigParseException(codeOwnerConfigKey, parser.getValidationErrors());
}
return codeOwnerConfig;
}
@Override
public String formatAsString(CodeOwnerConfig codeOwnerConfig) {
return Formatter.formatAsString(requireNonNull(codeOwnerConfig, "codeOwnerConfig"));
}
public static String replaceEmail(
String codeOwnerConfigFileContent, String oldEmail, String newEmail) {
requireNonNull(codeOwnerConfigFileContent, "codeOwnerConfigFileContent");
requireNonNull(oldEmail, "oldEmail");
requireNonNull(newEmail, "newEmail");
String charsThatCanAppearBeforeOrAfterEmail = "[\\s=,#]";
Pattern pattern =
Pattern.compile(
"(^|.*"
+ charsThatCanAppearBeforeOrAfterEmail
+ "+)"
+ "("
+ Pattern.quote(oldEmail)
+ ")"
+ "($|"
+ charsThatCanAppearBeforeOrAfterEmail
+ "+.*)");
List<String> updatedLines = new ArrayList<>();
for (String line : Splitter.onPattern(LINEBREAK_MATCHER).split(codeOwnerConfigFileContent)) {
while (pattern.matcher(line).matches()) {
line = pattern.matcher(line).replaceFirst("$1" + newEmail + "$3");
}
updatedLines.add(line);
}
return Joiner.on("\n").join(updatedLines);
}
@VisibleForTesting
static class Parser implements ValidationError.Sink {
private static final String COMMA = "[\\s]*,[\\s]*";
// Separator for project and file paths in an include line.
private static final String COLON = "[\\s]*:[\\s]*"; // project:file
private static final String BOL = "^[\\s]*"; // begin-of-line
private static final String EOL = "[\\s]*(#.*)?$"; // end-of-line
private static final String GLOB = "[^\\s,=]+"; // a file glob
private static final String EMAIL_OR_STAR = "([^\\s<>@,]+@[^\\s<>@#,]+|\\*)";
private static final String EMAIL_LIST =
"(" + EMAIL_OR_STAR + "(" + COMMA + EMAIL_OR_STAR + ")*)";
// Optional name of a Gerrit project followed by a colon and optional spaces.
private static final String PROJECT_NAME = "([^\\s:]+" + COLON + ")?";
// Optional name of a branch followed by a colon and optional spaces.
private static final String BRANCH_NAME = "([^\\s:]+" + COLON + ")?";
// A relative or absolute file path name without any colon or space character.
private static final String FILE_PATH = "([^\\s:#]+)";
private static final String PROJECT_BRANCH_AND_FILE = PROJECT_NAME + BRANCH_NAME + FILE_PATH;
private static final String SET_NOPARENT = "set[\\s]+noparent";
private static final String FILE_DIRECTIVE = "file:[\\s]*" + PROJECT_BRANCH_AND_FILE;
private static final String INCLUDE_DIRECTIVE = "include[\\s]+" + PROJECT_BRANCH_AND_FILE;
private static final String INCLUDE_OR_FILE = "(file:[\\s]*|include[\\s]+)";
// Simple input lines with 0 or 1 sub-pattern.
private static final Pattern PAT_COMMENT = Pattern.compile(BOL + EOL);
private static final Pattern PAT_EMAIL = Pattern.compile(BOL + EMAIL_OR_STAR + EOL);
private static final Pattern PAT_ANNOTATION = Pattern.compile("#\\{([A-Za-z_]+)\\}");
private static final Pattern PAT_INCLUDE =
Pattern.compile(BOL + INCLUDE_OR_FILE + PROJECT_BRANCH_AND_FILE + EOL);
private static final Pattern PAT_NO_PARENT = Pattern.compile(BOL + SET_NOPARENT + EOL);
private static final Pattern PAT_PER_FILE_OWNERS =
Pattern.compile("^(" + EMAIL_LIST + "|" + SET_NOPARENT + "|" + FILE_DIRECTIVE + ")$");
private static final Pattern PAT_PER_FILE_INCLUDE =
Pattern.compile("^(" + INCLUDE_DIRECTIVE + ")$");
private static final Pattern PAT_GLOBS =
Pattern.compile("^(" + GLOB + "(" + COMMA + GLOB + ")*)$");
// PAT_PER_FILE matches a line to two groups: (1) globs, (2) emails
// Trimmed 1st group should match PAT_GLOBS;
// trimmed 2nd group should match PAT_PER_FILE_OWNERS.
private static final Pattern PAT_PER_FILE =
Pattern.compile(BOL + "per-file[\\s]+([^=#]+)=[\\s]*([^#]+)" + EOL);
private ImmutableList.Builder<ValidationError> validationErrors;
CodeOwnerConfig parse(
ObjectId revision, CodeOwnerConfig.Key codeOwnerConfigKey, String codeOwnerConfigAsString) {
CodeOwnerConfig.Builder codeOwnerConfigBuilder =
CodeOwnerConfig.builder(codeOwnerConfigKey, revision);
CodeOwnerSet.Builder globalCodeOwnerSetBuilder = CodeOwnerSet.builder();
List<CodeOwnerSet> perFileCodeOwnerSet = new ArrayList<>();
for (String line : Splitter.onPattern(LINEBREAK_MATCHER).split(codeOwnerConfigAsString)) {
parseLine(codeOwnerConfigBuilder, globalCodeOwnerSetBuilder, perFileCodeOwnerSet, line);
}
// Make the code owners sets with the global code owners the first one in the list.
CodeOwnerSet globalCodeOwnersSet = globalCodeOwnerSetBuilder.build();
if (!globalCodeOwnersSet.codeOwners().isEmpty()) {
codeOwnerConfigBuilder.addCodeOwnerSet(globalCodeOwnersSet);
}
perFileCodeOwnerSet.forEach(codeOwnerConfigBuilder::addCodeOwnerSet);
return codeOwnerConfigBuilder.build();
}
private void parseLine(
CodeOwnerConfig.Builder codeOwnerConfigBuilder,
CodeOwnerSet.Builder globalCodeOwnerSetBuilder,
List<CodeOwnerSet> perFileCodeOwnerSets,
String line) {
ParsedEmailLine parsedEmailLine;
CodeOwnerSet codeOwnerSet;
CodeOwnerConfigReference codeOwnerConfigReference;
if (isNoParent(line)) {
codeOwnerConfigBuilder.setIgnoreParentCodeOwners();
} else if (isComment(line)) {
// ignore comment lines and empty lines
} else if ((parsedEmailLine = parseEmailLine(line)) != null) {
globalCodeOwnerSetBuilder.addCodeOwner(parsedEmailLine.codeOwnerReference());
globalCodeOwnerSetBuilder.addAnnotations(
parsedEmailLine.codeOwnerReference(), parsedEmailLine.annotations());
} else if ((codeOwnerSet = parsePerFileLine(line)) != null) {
perFileCodeOwnerSets.add(codeOwnerSet);
} else if ((codeOwnerConfigReference = parseInclude(line)) != null) {
codeOwnerConfigBuilder.addImport(codeOwnerConfigReference);
} else {
error(ValidationError.create(String.format("invalid line: %s", line)));
}
}
private CodeOwnerSet parsePerFileLine(String line) {
Matcher perFileMatcher = PAT_PER_FILE.matcher(line);
if (!perFileMatcher.matches() || !isGlobs(perFileMatcher.group(1).trim())) {
return null;
}
String matchedGroup2 = perFileMatcher.group(2).trim();
if (!PAT_PER_FILE_OWNERS.matcher(matchedGroup2).matches()) {
if (PAT_PER_FILE_INCLUDE.matcher(matchedGroup2).matches()) {
error(
ValidationError.create(
String.format(
"keyword 'include' is not supported for per file imports: %s", line)));
// return an empty code owner set to avoid that the line will be reported as invalid once
// more
return CodeOwnerSet.builder().build();
}
return null;
}
String[] globsAndOwners =
new String[] {
removeExtraSpaces(perFileMatcher.group(1)), removeExtraSpaces(perFileMatcher.group(2))
};
ImmutableSet<String> dirGlobs = splitGlobs(globsAndOwners[0]);
String directive = globsAndOwners[1];
if (directive.equals(TOK_SET_NOPARENT)) {
return CodeOwnerSet.builder()
.setIgnoreGlobalAndParentCodeOwners()
.setPathExpressions(dirGlobs)
.build();
}
CodeOwnerConfigReference codeOwnerConfigReference;
if ((codeOwnerConfigReference = parseInclude(directive)) != null) {
return CodeOwnerSet.builder()
.addImport(codeOwnerConfigReference)
.setPathExpressions(dirGlobs)
.build();
}
List<String> ownerEmails = Arrays.asList(directive.split(COMMA, -1));
// Get the comment part of the line (the first '#' and everything that follows).
String comment = perFileMatcher.group(3);
Set<CodeOwnerAnnotation> annotations = new HashSet<>();
if (comment != null) {
Matcher annotationMatcher = PAT_ANNOTATION.matcher(comment);
while (annotationMatcher.find()) {
String annotation = annotationMatcher.group(1);
annotations.add(CodeOwnerAnnotation.create(annotation));
}
}
CodeOwnerSet.Builder codeOwnerSet =
CodeOwnerSet.builder()
.setPathExpressions(dirGlobs)
.setCodeOwners(
ownerEmails.stream().map(CodeOwnerReference::create).collect(toImmutableSet()));
ownerEmails.stream()
.forEach(
email -> codeOwnerSet.addAnnotations(CodeOwnerReference.create(email), annotations));
return codeOwnerSet.build();
}
/**
* Splits the given glob string by the commas that separate the globs.
*
* <p>Commas that appear within a glob do not cause the string to be split at this position:
*
* <ul>
* <li>commas that are used as separator when matching choices via {@code {choice1,choice2}}
* <li>commas that appears as part of a character class via {@code
* [<any-chars-including-comma>]}
* </ul>
*
* @param commaSeparatedGlobs globs as comma-separated list
* @return the globs as array
*/
@VisibleForTesting
static ImmutableSet<String> splitGlobs(String commaSeparatedGlobs) {
ImmutableSet.Builder<String> globList = ImmutableSet.builder();
StringBuilder nextGlob = new StringBuilder();
int curlyBracesIndentionLevel = 0;
int squareBracesIndentionLevel = 0;
for (int i = 0; i < commaSeparatedGlobs.length(); i++) {
char c = commaSeparatedGlobs.charAt(i);
if (c == ',') {
if (curlyBracesIndentionLevel == 0 && squareBracesIndentionLevel == 0) {
globList.add(nextGlob.toString());
nextGlob = new StringBuilder();
} else {
nextGlob.append(c);
}
} else {
nextGlob.append(c);
if (c == '{') {
curlyBracesIndentionLevel++;
} else if (c == '}') {
if (curlyBracesIndentionLevel > 0) {
curlyBracesIndentionLevel--;
}
} else if (c == '[') {
squareBracesIndentionLevel++;
} else if (c == ']') {
if (squareBracesIndentionLevel > 0) {
squareBracesIndentionLevel--;
}
}
}
}
if (nextGlob.length() > 0) {
globList.add(nextGlob.toString());
}
return globList.build();
}
private static boolean isComment(String line) {
return PAT_COMMENT.matcher(line).matches();
}
private static boolean isNoParent(String line) {
return PAT_NO_PARENT.matcher(line).matches();
}
private static ParsedEmailLine parseEmailLine(String line) {
Matcher emailMatcher = PAT_EMAIL.matcher(line);
if (!emailMatcher.matches()) {
return null;
}
String email = emailMatcher.group(1).trim();
ParsedEmailLine.Builder parsedEmailLine = ParsedEmailLine.builder(email);
// Get the comment part of the line (the first '#' and everything that follows).
String comment = emailMatcher.group(2);
if (comment != null) {
Matcher annotationMatcher = PAT_ANNOTATION.matcher(comment);
while (annotationMatcher.find()) {
String annotation = annotationMatcher.group(1);
parsedEmailLine.addAnnotation(annotation);
}
}
return parsedEmailLine.build();
}
private static CodeOwnerConfigReference parseInclude(String line) {
Matcher m = Parser.PAT_INCLUDE.matcher(line);
if (!m.matches()) {
return null;
}
String keyword = m.group(1).trim();
CodeOwnerConfigImportMode importMode =
keyword.equals("include")
? CodeOwnerConfigImportMode.ALL
: CodeOwnerConfigImportMode.GLOBAL_CODE_OWNER_SETS_ONLY;
CodeOwnerConfigReference.Builder builder =
CodeOwnerConfigReference.builder(importMode, m.group(4).trim());
String projectName = m.group(2);
if (projectName != null && projectName.length() > 1) {
// PROJECT_NAME ends with ':'
projectName = projectName.split(COLON, -1)[0].trim();
builder.setProject(Project.nameKey(projectName));
String branchName = m.group(3);
if (branchName != null && branchName.length() > 1) {
// BRANCH_NAME ends with ':'
branchName = branchName.split(COLON, -1)[0].trim();
builder.setBranch(branchName);
}
}
return builder.build();
}
private static boolean isGlobs(String line) {
return PAT_GLOBS.matcher(line).matches();
}
private static String removeExtraSpaces(String s) {
return s.trim().replaceAll("[\\s]+", " ").replaceAll("[\\s]*:[\\s]*", ":");
}
/**
* Get the validation errors, if any were discovered during parsing the code owner config file.
*
* @return list of errors; empty list if there are no errors.
*/
public ImmutableList<ValidationError> getValidationErrors() {
if (validationErrors != null) {
return validationErrors.build();
}
return ImmutableList.of();
}
@Override
public void error(ValidationError error) {
if (validationErrors == null) {
validationErrors = ImmutableList.builder();
}
validationErrors.add(error);
}
}
private static class Formatter {
private static final String SET_NOPARENT_LINE = "set noparent\n";
// String format for a "per-file" line. The first placeholder is for the comma-separated list of
// path expressions, the second placeholder is for the comma-separated list of emails.
private static final String PER_FILE_LINE_FORMAT = "per-file %s=%s\n";
static String formatAsString(CodeOwnerConfig codeOwnerConfig) {
return formatIgnoreParentCodeOwners(codeOwnerConfig)
+ formatImports(codeOwnerConfig)
+ formatFolderCodeOwners(codeOwnerConfig)
+ formatPerFileCodeOwners(codeOwnerConfig);
}
private static String formatIgnoreParentCodeOwners(CodeOwnerConfig codeOwnerConfig) {
return codeOwnerConfig.ignoreParentCodeOwners() ? SET_NOPARENT_LINE : "";
}
private static String formatFolderCodeOwners(CodeOwnerConfig codeOwnerConfig) {
ImmutableSet<CodeOwnerSet> folderCodeOwnerSets =
codeOwnerConfig.codeOwnerSets().stream()
// Filter out code owner sets with path expressions. If path expressions are present
// the code owner set defines per-file code owners and is handled in
// formatPerFileCodeOwners(CodeOwnerConfig).
.filter(codeOwnerSet -> codeOwnerSet.pathExpressions().isEmpty())
.collect(toImmutableSet());
ImmutableList<String> emails =
folderCodeOwnerSets.stream()
.flatMap(codeOwnerSet -> codeOwnerSet.codeOwners().stream())
.map(CodeOwnerReference::email)
.sorted()
.distinct()
.collect(toImmutableList());
SortedSetMultimap<String, String> annotations = TreeMultimap.create();
folderCodeOwnerSets.forEach(
codeOwnerSet ->
codeOwnerSet
.annotations()
.forEach(
(codeOwnerReference, annotation) ->
annotations.put(codeOwnerReference.email(), annotation.key())));
StringBuilder b = new StringBuilder();
for (String email : emails) {
b.append(email);
annotations.get(email).forEach(annotation -> b.append(" #{" + annotation + "}"));
b.append('\n');
}
return b.toString();
}
private static String formatPerFileCodeOwners(CodeOwnerConfig codeOwnerConfig) {
StringBuilder b = new StringBuilder();
codeOwnerConfig.codeOwnerSets().stream()
// Filter out code owner sets without path expressions. If path expressions are absent the
// code owner set defines global code owners and is handled in
// formatGlobalCodeOwners(CodeOwnerConfig).
.filter(codeOwnerSet -> !codeOwnerSet.pathExpressions().isEmpty())
.map(Formatter::formatCodeOwnerSet)
.forEach(b::append);
return b.toString();
}
private static String formatCodeOwnerSet(CodeOwnerSet codeOwnerSet) {
String formattedPathExpressions = formatValuesAsList(codeOwnerSet.pathExpressions());
StringBuilder b = new StringBuilder();
if (codeOwnerSet.ignoreGlobalAndParentCodeOwners()) {
b.append(String.format(PER_FILE_LINE_FORMAT, formattedPathExpressions, TOK_SET_NOPARENT));
}
for (CodeOwnerConfigReference codeOwnerConfigReference : codeOwnerSet.imports()) {
b.append(
String.format(
PER_FILE_LINE_FORMAT,
formattedPathExpressions,
formatImport(codeOwnerConfigReference)));
}
if (!codeOwnerSet.codeOwners().isEmpty()) {
// group code owners that have the same annotations
ListMultimap<SortedSet<String>, CodeOwnerReference> codeOwnersByAnnotations =
MultimapBuilder.hashKeys().arrayListValues().build();
codeOwnerSet
.codeOwners()
.forEach(
codeOwnerReference ->
codeOwnersByAnnotations.put(
codeOwnerSet.annotations().get(codeOwnerReference).stream()
.map(CodeOwnerAnnotation::key)
.collect(toImmutableSortedSet(naturalOrder())),
codeOwnerReference));
codeOwnersByAnnotations
.asMap()
.forEach(
(annotations, codeOwners) ->
b.append(
String.format(
PER_FILE_LINE_FORMAT,
formattedPathExpressions,
formatCodeOwnerReferencesAsList(codeOwners)
+ formatAnnotations(annotations))));
}
return b.toString();
}
private static String formatCodeOwnerReferencesAsList(
Collection<CodeOwnerReference> codeOwnerReferences) {
return formatValuesAsList(codeOwnerReferences.stream().map(CodeOwnerReference::email));
}
private static String formatAnnotations(SortedSet<String> annotations) {
if (annotations.isEmpty()) {
return "";
}
return annotations.stream()
.map(annotation -> "#{" + annotation + "}")
.collect(joining(" ", " ", ""));
}
private static String formatValuesAsList(ImmutableSet<String> values) {
return formatValuesAsList(values.stream());
}
private static String formatValuesAsList(Stream<String> stream) {
return stream.sorted().distinct().collect(joining(","));
}
private static String formatImports(CodeOwnerConfig codeOwnerConfig) {
StringBuilder b = new StringBuilder();
codeOwnerConfig
.imports()
.forEach(
codeOwnerConfigReference ->
b.append(formatImport(codeOwnerConfigReference)).append('\n'));
return b.toString();
}
private static String formatImport(CodeOwnerConfigReference codeOwnerConfigReference) {
StringBuilder b = new StringBuilder();
// write the keyword
switch (codeOwnerConfigReference.importMode()) {
case ALL:
b.append("include ");
break;
case GLOBAL_CODE_OWNER_SETS_ONLY:
b.append("file: ");
break;
default:
throw new IllegalStateException(
String.format("unknown import mode: %s", codeOwnerConfigReference.importMode()));
}
// write the project
if (codeOwnerConfigReference.project().isPresent()) {
b.append(codeOwnerConfigReference.project().get()).append(':');
}
// write the branch
if (codeOwnerConfigReference.branch().isPresent()) {
checkState(
codeOwnerConfigReference.project().isPresent(),
"project is required if branch is specified: %s",
codeOwnerConfigReference);
b.append(codeOwnerConfigReference.branch().get()).append(':');
}
// write the file path
b.append(codeOwnerConfigReference.filePath());
return b.toString();
}
}
}