| // 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(); |
| } |
| } |
| } |