blob: e7e437a9f59dae2604e966b993af64f3e8f6d5ac [file] [log] [blame]
// 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 com.google.gerrit.server.project.ProjectState;
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)
throws InvalidOwnersFileException {
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)
throws InvalidOwnersFileException {
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)
throws InvalidOwnersFileException {
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;
if (branchWhenEnabled.isPresent()) {
map = fetchOwners(project, branchWhenEnabled.get(), cache);
} else {
map = 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;
}
public static List<Project.NameKey> getParents(ProjectState projectState) {
return projectState.parents().stream()
.map(ProjectState::getNameKey)
.collect(Collectors.toList());
}
/**
* Fetched the owners for the associated patch list.
*
* @return A structure containing matchers paths to owners
* @throws InvalidOwnersFileException when reading/parsing OWNERS file fails
*/
private OwnersMap fetchOwners(String project, String branch, PathOwnersEntriesCache cache)
throws InvalidOwnersFileException {
OwnersMap ownersMap = new OwnersMap();
try {
// Using a `map` would have needed a try/catch inside the lamba, resulting in more code
List<ReadOnlyPathOwnersEntry> parentsPathOwnersEntries =
getPathOwnersEntries(parentProjectsNames, RefNames.REFS_CONFIG, cache);
ReadOnlyPathOwnersEntry projectEntry =
getPathOwnersEntryOrEmpty(project, repository, RefNames.REFS_CONFIG, cache);
PathOwnersEntry rootEntry = getPathOwnersEntryOrNew(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));
} catch (IOException e) {
log.warn("Opening repository {} failed", project, e);
} catch (ExecutionException e) {
log.warn("Reading OWNERS file for {} project, from cache failed", project, e);
}
return ownersMap;
}
private List<ReadOnlyPathOwnersEntry> getPathOwnersEntries(
List<Project.NameKey> projectNames, String branch, PathOwnersEntriesCache cache)
throws IOException, InvalidOwnersFileException, ExecutionException {
ImmutableList.Builder<ReadOnlyPathOwnersEntry> pathOwnersEntries = ImmutableList.builder();
for (Project.NameKey projectName : projectNames) {
try (Repository repo = repositoryManager.openRepository(projectName)) {
pathOwnersEntries =
pathOwnersEntries.add(
getPathOwnersEntryOrEmpty(projectName.get(), repo, branch, cache));
}
}
return pathOwnersEntries.build();
}
private ReadOnlyPathOwnersEntry getPathOwnersEntryOrEmpty(
String project, Repository repo, String branch, PathOwnersEntriesCache cache)
throws InvalidOwnersFileException, ExecutionException {
return getPathOwnersEntry(project, repo, branch, cache)
.map(v -> (ReadOnlyPathOwnersEntry) v)
.orElse(PathOwnersEntry.EMPTY);
}
private PathOwnersEntry getPathOwnersEntryOrNew(
String project, Repository repo, String branch, PathOwnersEntriesCache cache)
throws InvalidOwnersFileException, ExecutionException {
return getPathOwnersEntry(project, repo, branch, cache).orElseGet(PathOwnersEntry::new);
}
private Optional<PathOwnersEntry> getPathOwnersEntry(
String project, Repository repo, String branch, PathOwnersEntriesCache cache)
throws InvalidOwnersFileException, ExecutionException {
String rootPath = "OWNERS";
return unwrapInvalidOwnersFileException(
() ->
cache
.get(
project,
branch,
rootPath,
() -> getOwnersConfig(project, repo, rootPath, branch))
.map(
conf ->
new PathOwnersEntry(
rootPath,
conf,
accounts,
Optional.empty(),
Collections.emptySet(),
Collections.emptySet(),
Collections.emptySet(),
Collections.emptySet())));
}
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,
ReadOnlyPathOwnersEntry projectEntry,
List<ReadOnlyPathOwnersEntry> parentsPathOwnersEntries,
PathOwnersEntry rootEntry,
Map<String, PathOwnersEntry> entries,
PathOwnersEntriesCache cache)
throws InvalidOwnersFileException, 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 (ReadOnlyPathOwnersEntry 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 =
unwrapInvalidOwnersFileException(
() ->
cache
.get(
project,
branch,
ownersPath,
() -> getOwnersConfig(project, 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(
ReadOnlyPathOwnersEntry rootEntry,
ReadOnlyPathOwnersEntry 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.
*
* @return Optional(config) or Optional.empty if it doesn't exist
* @throws InvalidOwnersFileException when reading/parsing of the OWNERS file fails
*/
private Optional<OwnersConfig> getOwnersConfig(
String project, Repository repo, String ownersPath, String branch)
throws InvalidOwnersFileException {
try {
Optional<byte[]> configBytes = getBlobAsBytes(repo, branch, ownersPath);
if (configBytes.isEmpty()) {
return Optional.empty();
}
return Optional.of(parser.getOwnersConfig(configBytes.get()));
} catch (IOException e) {
throw new InvalidOwnersFileException(project, ownersPath, branch, e);
}
}
@FunctionalInterface
private interface Executable<T> {
T call() throws ExecutionException;
}
/**
* Unwraps the InvalidOwnersFileException from the ExecutionException so that callers can focus on
* handling InvalidOwnersFileException if/when needed. Note that ExecutionException is thrown only
* when loading values to PathOwnersEntriesCache fails.
*
* @throws InvalidOwnersFileException when call throws ExecutionException that is matchable with
* InvalidOwnersFileException
* @throws ExecutionException in all other cases
*/
private <T> T unwrapInvalidOwnersFileException(Executable<T> action)
throws InvalidOwnersFileException, ExecutionException {
try {
return action.call();
} catch (ExecutionException e) {
if (e.getCause() instanceof InvalidOwnersFileException) {
throw (InvalidOwnersFileException) e.getCause();
}
throw e;
}
}
}