| // Copyright (c) 2013 VMware, Inc. All Rights Reserved. |
| // Copyright (C) 2017 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.owners.common; |
| |
| import static com.google.gerrit.entities.Patch.COMMIT_MSG; |
| import static com.google.gerrit.entities.Patch.MERGE_LIST; |
| import static com.googlesource.gerrit.owners.common.JgitWrapper.getBlobAsBytes; |
| |
| import com.google.common.collect.ImmutableList; |
| import com.google.common.collect.ImmutableSet; |
| import com.google.common.collect.Maps; |
| import com.google.common.collect.Multimaps; |
| import com.google.common.collect.SetMultimap; |
| import com.google.common.collect.Sets; |
| import com.google.gerrit.common.Nullable; |
| import com.google.gerrit.entities.Account; |
| import com.google.gerrit.entities.Account.Id; |
| import com.google.gerrit.entities.Patch; |
| import com.google.gerrit.entities.Project; |
| import com.google.gerrit.entities.RefNames; |
| import com.google.gerrit.server.git.GitRepositoryManager; |
| import com.google.gerrit.server.patch.DiffSummary; |
| import com.google.gerrit.server.patch.filediff.FileDiffOutput; |
| import java.io.IOException; |
| import java.util.Collection; |
| import java.util.Collections; |
| import java.util.HashMap; |
| import java.util.List; |
| import java.util.Map; |
| import java.util.Optional; |
| import java.util.Set; |
| import java.util.concurrent.ExecutionException; |
| import java.util.stream.Collectors; |
| import org.eclipse.jgit.lib.Repository; |
| import org.slf4j.Logger; |
| import org.slf4j.LoggerFactory; |
| |
| /** Calculates the owners of a patch list. */ |
| // TODO(vspivak): provide assisted factory |
| public class PathOwners { |
| |
| private static final Logger log = LoggerFactory.getLogger(PathOwners.class); |
| |
| private enum MatcherLevel { |
| Regular, |
| Fallback, |
| CatchAll; |
| |
| static MatcherLevel forMatcher(Matcher matcher) { |
| return matcher instanceof GenericMatcher |
| ? (matcher.path.equals(".*") ? CatchAll : Fallback) |
| : Regular; |
| } |
| } |
| |
| private final SetMultimap<String, Account.Id> owners; |
| |
| private final SetMultimap<String, Account.Id> reviewers; |
| |
| private final Repository repository; |
| |
| private final List<Project.NameKey> parentProjectsNames; |
| |
| private final ConfigurationParser parser; |
| |
| private final Set<String> modifiedPaths; |
| |
| private final Accounts accounts; |
| |
| private final GitRepositoryManager repositoryManager; |
| |
| private Map<String, Matcher> matchers; |
| |
| private Map<String, Set<Id>> fileOwners; |
| |
| private Map<String, Set<String>> fileGroupOwners; |
| |
| private final boolean expandGroups; |
| |
| private final Optional<LabelDefinition> label; |
| |
| public PathOwners( |
| Accounts accounts, |
| GitRepositoryManager repositoryManager, |
| Repository repository, |
| List<Project.NameKey> parentProjectsNames, |
| Optional<String> branchWhenEnabled, |
| Map<String, FileDiffOutput> fileDiffMap, |
| boolean expandGroups, |
| String project, |
| PathOwnersEntriesCache cache) { |
| this( |
| accounts, |
| repositoryManager, |
| repository, |
| parentProjectsNames, |
| branchWhenEnabled, |
| getModifiedPaths(fileDiffMap), |
| expandGroups, |
| project, |
| cache); |
| } |
| |
| public PathOwners( |
| Accounts accounts, |
| GitRepositoryManager repositoryManager, |
| Repository repository, |
| List<Project.NameKey> parentProjectsNames, |
| Optional<String> branchWhenEnabled, |
| DiffSummary diffSummary, |
| boolean expandGroups, |
| String project, |
| PathOwnersEntriesCache cache) { |
| this( |
| accounts, |
| repositoryManager, |
| repository, |
| parentProjectsNames, |
| branchWhenEnabled, |
| ImmutableSet.copyOf(diffSummary.getPaths()), |
| expandGroups, |
| project, |
| cache); |
| } |
| |
| public PathOwners( |
| Accounts accounts, |
| GitRepositoryManager repositoryManager, |
| Repository repository, |
| List<Project.NameKey> parentProjectsNames, |
| Optional<String> branchWhenEnabled, |
| Set<String> modifiedPaths, |
| boolean expandGroups, |
| String project, |
| PathOwnersEntriesCache cache) { |
| this.repositoryManager = repositoryManager; |
| this.repository = repository; |
| this.parentProjectsNames = parentProjectsNames; |
| this.modifiedPaths = modifiedPaths; |
| this.parser = new ConfigurationParser(accounts); |
| this.accounts = accounts; |
| this.expandGroups = expandGroups; |
| |
| OwnersMap map = |
| branchWhenEnabled |
| .map(branch -> fetchOwners(project, branch, cache)) |
| .orElse(new OwnersMap()); |
| owners = Multimaps.unmodifiableSetMultimap(map.getPathOwners()); |
| reviewers = Multimaps.unmodifiableSetMultimap(map.getPathReviewers()); |
| matchers = map.getMatchers(); |
| fileOwners = map.getFileOwners(); |
| fileGroupOwners = map.getFileGroupOwners(); |
| label = map.getLabel(); |
| } |
| /** |
| * Returns a read only view of the paths to owners mapping. |
| * |
| * @return multimap of paths to owners |
| */ |
| public SetMultimap<String, Account.Id> get() { |
| return owners; |
| } |
| |
| /** |
| * Returns a read only view of the paths to reviewers mapping. |
| * |
| * @return multimap of paths to reviewers |
| */ |
| public SetMultimap<String, Account.Id> getReviewers() { |
| return reviewers; |
| } |
| |
| public Map<String, Matcher> getMatchers() { |
| return matchers; |
| } |
| |
| public Map<String, Set<Account.Id>> getFileOwners() { |
| return fileOwners; |
| } |
| |
| public Map<String, Set<String>> getFileGroupOwners() { |
| return fileGroupOwners; |
| } |
| |
| public boolean expandGroups() { |
| return expandGroups; |
| } |
| |
| public Optional<LabelDefinition> getLabel() { |
| return label; |
| } |
| |
| /** |
| * Fetched the owners for the associated patch list. |
| * |
| * @return A structure containing matchers paths to owners |
| */ |
| private OwnersMap fetchOwners(String project, String branch, PathOwnersEntriesCache cache) { |
| OwnersMap ownersMap = new OwnersMap(); |
| try { |
| // Using a `map` would have needed a try/catch inside the lamba, resulting in more code |
| List<PathOwnersEntry> parentsPathOwnersEntries = |
| getPathOwnersEntries(parentProjectsNames, RefNames.REFS_CONFIG, cache); |
| PathOwnersEntry projectEntry = |
| getPathOwnersEntry(project, repository, RefNames.REFS_CONFIG, cache); |
| PathOwnersEntry rootEntry = getPathOwnersEntry(project, repository, branch, cache); |
| |
| Map<String, PathOwnersEntry> entries = new HashMap<>(); |
| PathOwnersEntry currentEntry = null; |
| for (String path : modifiedPaths) { |
| currentEntry = |
| resolvePathEntry( |
| project, |
| path, |
| branch, |
| projectEntry, |
| parentsPathOwnersEntries, |
| rootEntry, |
| entries, |
| cache); |
| |
| // add owners and reviewers to file for matcher predicates |
| ownersMap.addFileOwners(path, currentEntry.getOwners()); |
| ownersMap.addFileReviewers(path, currentEntry.getReviewers()); |
| ownersMap.addFileGroupOwners(path, currentEntry.getGroupOwners()); |
| |
| // Only add the path to the OWNERS file to reduce the number of |
| // entries in the result |
| if (currentEntry.getOwnersPath() != null) { |
| ownersMap.addPathOwners(currentEntry.getOwnersPath(), currentEntry.getOwners()); |
| ownersMap.addPathReviewers(currentEntry.getOwnersPath(), currentEntry.getReviewers()); |
| } |
| ownersMap.addMatchers(currentEntry.getMatchers()); |
| } |
| |
| // We need to only keep matchers that match files in the patchset |
| Map<String, Matcher> matchers = ownersMap.getMatchers(); |
| if (matchers.size() > 0) { |
| HashMap<String, Matcher> newMatchers = Maps.newHashMap(); |
| // extra loop |
| for (String path : modifiedPaths) { |
| processMatcherPerPath(matchers, newMatchers, path, ownersMap); |
| } |
| if (matchers.size() != newMatchers.size()) { |
| ownersMap.setMatchers(newMatchers); |
| } |
| } |
| ownersMap.setLabel(Optional.ofNullable(currentEntry).flatMap(PathOwnersEntry::getLabel)); |
| return ownersMap; |
| } catch (IOException | ExecutionException e) { |
| log.warn("Invalid OWNERS file", e); |
| return ownersMap; |
| } |
| } |
| |
| private List<PathOwnersEntry> getPathOwnersEntries( |
| List<Project.NameKey> projectNames, String branch, PathOwnersEntriesCache cache) |
| throws IOException, ExecutionException { |
| ImmutableList.Builder<PathOwnersEntry> pathOwnersEntries = ImmutableList.builder(); |
| for (Project.NameKey projectName : projectNames) { |
| try (Repository repo = repositoryManager.openRepository(projectName)) { |
| pathOwnersEntries = |
| pathOwnersEntries.add(getPathOwnersEntry(projectName.get(), repo, branch, cache)); |
| } |
| } |
| return pathOwnersEntries.build(); |
| } |
| |
| private PathOwnersEntry getPathOwnersEntry( |
| String project, Repository repo, String branch, PathOwnersEntriesCache cache) |
| throws ExecutionException { |
| String rootPath = "OWNERS"; |
| return cache.get( |
| project, |
| branch, |
| rootPath, |
| () -> |
| getOwnersConfig(repo, rootPath, branch) |
| .map( |
| conf -> |
| new PathOwnersEntry( |
| rootPath, |
| conf, |
| accounts, |
| Optional.empty(), |
| Collections.emptySet(), |
| Collections.emptySet(), |
| Collections.emptySet(), |
| Collections.emptySet())) |
| .orElse(PathOwnersEntry.EMPTY)); |
| } |
| |
| private void processMatcherPerPath( |
| Map<String, Matcher> fullMatchers, |
| HashMap<String, Matcher> newMatchers, |
| String path, |
| OwnersMap ownersMap) { |
| |
| Map<MatcherLevel, List<Matcher>> matchersByLevel = |
| fullMatchers.values().stream().collect(Collectors.groupingBy(MatcherLevel::forMatcher)); |
| if (findAndAddMatchers( |
| newMatchers, path, ownersMap, matchersByLevel.get(MatcherLevel.Regular))) { |
| return; |
| } |
| |
| if (findAndAddMatchers( |
| newMatchers, path, ownersMap, matchersByLevel.get(MatcherLevel.Fallback))) { |
| return; |
| } |
| |
| findAndAddMatchers(newMatchers, path, ownersMap, matchersByLevel.get(MatcherLevel.CatchAll)); |
| } |
| |
| private boolean findAndAddMatchers( |
| HashMap<String, Matcher> newMatchers, |
| String path, |
| OwnersMap ownersMap, |
| @Nullable List<Matcher> matchers) { |
| if (matchers == null) { |
| return false; |
| } |
| |
| boolean matchingFound = false; |
| |
| for (Matcher matcher : matchers) { |
| if (matcher.matches(path)) { |
| newMatchers.put(matcher.getPath(), matcher); |
| ownersMap.addFileOwners(path, matcher.getOwners()); |
| ownersMap.addFileGroupOwners(path, matcher.getGroupOwners()); |
| ownersMap.addFileReviewers(path, matcher.getReviewers()); |
| matchingFound = true; |
| } |
| } |
| return matchingFound; |
| } |
| |
| private PathOwnersEntry resolvePathEntry( |
| String project, |
| String path, |
| String branch, |
| PathOwnersEntry projectEntry, |
| List<PathOwnersEntry> parentsPathOwnersEntries, |
| PathOwnersEntry rootEntry, |
| Map<String, PathOwnersEntry> entries, |
| PathOwnersEntriesCache cache) |
| throws ExecutionException { |
| String[] parts = path.split("/"); |
| PathOwnersEntry currentEntry = rootEntry; |
| StringBuilder builder = new StringBuilder(); |
| |
| // Inherit from Project if OWNER in root enables inheritance |
| calculateCurrentEntry(rootEntry, projectEntry, currentEntry); |
| |
| // Inherit from Parent Project if OWNER in Project enables inheritance |
| for (PathOwnersEntry parentPathOwnersEntry : parentsPathOwnersEntries) { |
| calculateCurrentEntry(projectEntry, parentPathOwnersEntry, currentEntry); |
| } |
| |
| // Iterate through the parent paths, not including the file name |
| // itself |
| for (int i = 0; i < parts.length - 1; i++) { |
| String part = parts[i]; |
| builder.append(part).append("/"); |
| String partial = builder.toString(); |
| |
| // Skip if we already parsed this path |
| if (entries.containsKey(partial)) { |
| currentEntry = entries.get(partial); |
| } else { |
| String ownersPath = partial + "OWNERS"; |
| PathOwnersEntry pathFallbackEntry = currentEntry; |
| currentEntry = |
| cache.get( |
| project, |
| branch, |
| ownersPath, |
| () -> |
| getOwnersConfig(repository, ownersPath, branch) |
| .map( |
| c -> { |
| Optional<LabelDefinition> label = pathFallbackEntry.getLabel(); |
| final Set<Id> owners = pathFallbackEntry.getOwners(); |
| final Set<Id> reviewers = pathFallbackEntry.getReviewers(); |
| Collection<Matcher> inheritedMatchers = |
| pathFallbackEntry.getMatchers().values(); |
| Set<String> groupOwners = pathFallbackEntry.getGroupOwners(); |
| return new PathOwnersEntry( |
| ownersPath, |
| c, |
| accounts, |
| label, |
| owners, |
| reviewers, |
| inheritedMatchers, |
| groupOwners); |
| }) |
| .orElse(pathFallbackEntry)); |
| entries.put(partial, currentEntry); |
| } |
| } |
| return currentEntry; |
| } |
| |
| private void calculateCurrentEntry( |
| PathOwnersEntry rootEntry, PathOwnersEntry projectEntry, PathOwnersEntry currentEntry) { |
| if (rootEntry.isInherited()) { |
| for (Matcher matcher : projectEntry.getMatchers().values()) { |
| if (!currentEntry.hasMatcher(matcher.getPath())) { |
| currentEntry.addMatcher(matcher); |
| } |
| } |
| if (currentEntry.getOwners().isEmpty()) { |
| currentEntry.setOwners(projectEntry.getOwners()); |
| } |
| if (currentEntry.getOwnersPath() == null) { |
| currentEntry.setOwnersPath(projectEntry.getOwnersPath()); |
| } |
| if (currentEntry.getLabel().isEmpty()) { |
| currentEntry.setLabel(projectEntry.getLabel()); |
| } |
| } |
| } |
| |
| /** |
| * Parses the diff list for any paths that were modified. |
| * |
| * @return set of modified paths. |
| */ |
| private static Set<String> getModifiedPaths(Map<String, FileDiffOutput> patchList) { |
| Set<String> paths = Sets.newHashSet(); |
| for (Map.Entry<String, FileDiffOutput> patch : patchList.entrySet()) { |
| // Ignore commit message and Merge List |
| String newName = patch.getKey(); |
| if (!COMMIT_MSG.equals(newName) && !MERGE_LIST.equals(newName)) { |
| paths.add(newName); |
| |
| // If a file was moved then we need approvals for old and new |
| // path |
| if (patch.getValue().changeType() == Patch.ChangeType.RENAMED) { |
| paths.add(patch.getValue().oldPath().get()); |
| } |
| } |
| } |
| return paths; |
| } |
| |
| /** |
| * Returns the parsed FileOwnersConfig file for the given path if it exists. |
| * |
| * @param ownersPath path to OWNERS file in the git repo |
| * @return config or null if it doesn't exist |
| * @throws IOException |
| */ |
| private Optional<OwnersConfig> getOwnersConfig(Repository repo, String ownersPath, String branch) |
| throws IOException { |
| return getBlobAsBytes(repo, branch, ownersPath).flatMap(parser::getOwnersConfig); |
| } |
| } |