blob: 136ffcb6ea732380e43e502e763431718a837625 [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.validation;
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.gerrit.plugins.codeowners.backend.CodeOwners.getInvalidCodeOwnerConfigCause;
import static java.util.Objects.requireNonNull;
import com.google.auto.value.AutoValue;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableListMultimap;
import com.google.common.collect.ImmutableSet;
import com.google.common.collect.Streams;
import com.google.common.flogger.FluentLogger;
import com.google.gerrit.entities.BranchNameKey;
import com.google.gerrit.entities.PatchSet;
import com.google.gerrit.entities.Project;
import com.google.gerrit.extensions.annotations.PluginName;
import com.google.gerrit.extensions.restapi.AuthException;
import com.google.gerrit.plugins.codeowners.backend.ChangedFiles;
import com.google.gerrit.plugins.codeowners.backend.CodeOwnerBackend;
import com.google.gerrit.plugins.codeowners.backend.CodeOwnerConfig;
import com.google.gerrit.plugins.codeowners.backend.CodeOwnerConfigImportType;
import com.google.gerrit.plugins.codeowners.backend.CodeOwnerConfigReference;
import com.google.gerrit.plugins.codeowners.backend.CodeOwnerReference;
import com.google.gerrit.plugins.codeowners.backend.CodeOwnerResolver;
import com.google.gerrit.plugins.codeowners.backend.CodeOwnersInternalServerErrorException;
import com.google.gerrit.plugins.codeowners.backend.InvalidCodeOwnerConfigException;
import com.google.gerrit.plugins.codeowners.backend.PathCodeOwners;
import com.google.gerrit.plugins.codeowners.backend.config.CodeOwnersPluginConfiguration;
import com.google.gerrit.plugins.codeowners.backend.config.CodeOwnersPluginProjectConfigSnapshot;
import com.google.gerrit.plugins.codeowners.backend.config.InvalidPluginConfigurationException;
import com.google.gerrit.plugins.codeowners.common.ChangedFile;
import com.google.gerrit.plugins.codeowners.common.CodeOwnerConfigValidationPolicy;
import com.google.gerrit.plugins.codeowners.common.MergeCommitStrategy;
import com.google.gerrit.plugins.codeowners.metrics.CodeOwnerMetrics;
import com.google.gerrit.plugins.codeowners.metrics.ValidationTrigger;
import com.google.gerrit.plugins.codeowners.util.JgitPath;
import com.google.gerrit.server.IdentifiedUser;
import com.google.gerrit.server.PatchSetUtil;
import com.google.gerrit.server.events.CommitReceivedEvent;
import com.google.gerrit.server.git.CodeReviewCommit;
import com.google.gerrit.server.git.CodeReviewCommit.CodeReviewRevWalk;
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.MergeValidationException;
import com.google.gerrit.server.git.validators.MergeValidationListener;
import com.google.gerrit.server.git.validators.ValidationMessage;
import com.google.gerrit.server.logging.Metadata;
import com.google.gerrit.server.logging.TraceContext;
import com.google.gerrit.server.logging.TraceContext.TraceTimer;
import com.google.gerrit.server.notedb.ChangeNotes;
import com.google.gerrit.server.patch.DiffNotAvailableException;
import com.google.gerrit.server.permissions.PermissionBackend;
import com.google.gerrit.server.permissions.PermissionBackendException;
import com.google.gerrit.server.permissions.ProjectPermission;
import com.google.gerrit.server.permissions.RefPermission;
import com.google.gerrit.server.project.ProjectCache;
import com.google.gerrit.server.project.ProjectState;
import com.google.inject.Inject;
import com.google.inject.Provider;
import com.google.inject.Singleton;
import java.io.IOException;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.List;
import java.util.Optional;
import java.util.stream.Stream;
import org.eclipse.jgit.errors.ConfigInvalidException;
import org.eclipse.jgit.lib.ObjectId;
import org.eclipse.jgit.lib.Ref;
import org.eclipse.jgit.lib.Repository;
import org.eclipse.jgit.revwalk.RevCommit;
import org.eclipse.jgit.revwalk.RevWalk;
/**
* Validates modifications to the code owner config files.
*
* <p>The validations are best effort to prevent invalid code owner configs from entering the
* repository, but we cannot prevent it in all cases. Still the validation is useful since it
* prevents most issues and also gives quick feedback to uploaders about typos (e.g. if an email is
* misspelled it's not breaking anything, but the intended change of the uploader is not working).
*
* <p>Code owner configs are not validated when:
*
* <ul>
* <li>the {@code code-owners} plugin is not installed (this means when the {@code code-owners}
* plugin gets installed it is possible that invalid code owner configs already exist in the
* repository)
* <li>the code owners functionality is disabled for the repository or branch (this means when the
* code owners functionality gets enabled it is possible that invalid code owner configs
* already exist in the repository)
* <li>the {@code code-owners} plugin configuration is invalid (in this case we don't know which
* files are code owner config files, so we allow all uploads rather than blocking all
* uploads, to reduce the risk of breaking the plugin configuration {@code
* com.google.gerrit.plugins.codeowners.config.CodeOwnersPluginConfigValidator} takes care to
* validate modifications to the plugin configuration)
* <li>updates happen behind Gerrit's back (e.g. pushes that bypass Gerrit)
* </ul>
*
* <p>In addition it is possible that code owner config files get invalid after they have been
* submitted:
*
* <ul>
* <li>configuration parameters that are relevant for the validation are changed (e.g. the account
* visibility is changed, another code owners backend is configured which now uses a different
* syntax or different names for code owner config files, or the file extension for code owner
* config file is set/changed)
* <li>emails of user may change so that emails in code owner configs can no longer be resolved
* <li>imported code owner config files may get deleted or renamed so that the import references
* can no longer be resolved
* </ul>
*/
@Singleton
public class CodeOwnerConfigValidator implements CommitValidationListener, MergeValidationListener {
private static final FluentLogger logger = FluentLogger.forEnclosingClass();
private final String pluginName;
private final CodeOwnersPluginConfiguration codeOwnersPluginConfiguration;
private final GitRepositoryManager repoManager;
private final ChangedFiles changedFiles;
private final Provider<CodeOwnerResolver> codeOwnerResolverProvider;
private final PermissionBackend permissionBackend;
private final ProjectCache projectCache;
private final ChangeNotes.Factory changeNotesFactory;
private final PatchSetUtil patchSetUtil;
private final IdentifiedUser.GenericFactory userFactory;
private final SkipCodeOwnerConfigValidationPushOption skipCodeOwnerConfigValidationPushOption;
private final CodeOwnerMetrics codeOwnerMetrics;
@Inject
CodeOwnerConfigValidator(
@PluginName String pluginName,
CodeOwnersPluginConfiguration codeOwnersPluginConfiguration,
GitRepositoryManager repoManager,
ChangedFiles changedFiles,
Provider<CodeOwnerResolver> codeOwnerResolver,
PermissionBackend permissionBackend,
ProjectCache projectCache,
ChangeNotes.Factory changeNotesFactory,
PatchSetUtil patchSetUtil,
IdentifiedUser.GenericFactory userFactory,
SkipCodeOwnerConfigValidationPushOption skipCodeOwnerConfigValidationPushOption,
CodeOwnerMetrics codeOwnerMetrics) {
this.pluginName = pluginName;
this.codeOwnersPluginConfiguration = codeOwnersPluginConfiguration;
this.repoManager = repoManager;
this.changedFiles = changedFiles;
this.codeOwnerResolverProvider = codeOwnerResolver;
this.permissionBackend = permissionBackend;
this.projectCache = projectCache;
this.changeNotesFactory = changeNotesFactory;
this.patchSetUtil = patchSetUtil;
this.userFactory = userFactory;
this.skipCodeOwnerConfigValidationPushOption = skipCodeOwnerConfigValidationPushOption;
this.codeOwnerMetrics = codeOwnerMetrics;
}
@Override
public List<CommitValidationMessage> onCommitReceived(CommitReceivedEvent receiveEvent)
throws CommitValidationException {
try (TraceTimer traceTimer =
TraceContext.newTimer(
"Validate code owner config files on commit received",
Metadata.builder()
.projectName(receiveEvent.project.getName())
.commit(receiveEvent.commit.name())
.branchName(receiveEvent.refName)
.username(receiveEvent.user.getLoggableName())
.build())) {
CodeOwnerConfigValidationPolicy codeOwnerConfigValidationPolicy =
codeOwnersPluginConfiguration
.getProjectConfig(receiveEvent.getProjectNameKey())
.getCodeOwnerConfigValidationPolicyForCommitReceived(receiveEvent.refName);
logger.atFine().log("codeOwnerConfigValidationPolicy = %s", codeOwnerConfigValidationPolicy);
boolean metricRecordingDone = false;
Optional<ValidationResult> validationResult;
if (!codeOwnerConfigValidationPolicy.runValidation()) {
validationResult =
Optional.of(
ValidationResult.create(
pluginName,
"skipping validation of code owner config files",
new CommitValidationMessage(
"code owners config validation is disabled", ValidationMessage.Type.HINT)));
} else {
try {
validationResult =
validateCodeOwnerConfig(
receiveEvent.getBranchNameKey(),
receiveEvent.commit,
receiveEvent.user,
codeOwnerConfigValidationPolicy.isForced(),
receiveEvent.pushOptions);
} catch (RuntimeException e) {
codeOwnerMetrics.countCodeOwnerConfigValidations.increment(
ValidationTrigger.COMMIT_RECEIVED,
com.google.gerrit.plugins.codeowners.metrics.ValidationResult.FAILED,
codeOwnerConfigValidationPolicy.isDryRun());
metricRecordingDone = true;
if (!codeOwnerConfigValidationPolicy.isDryRun()) {
throw e;
}
// The validation was executed as dry-run and failures during the validation should not
// cause an error. Hence we swallow the exception here.
logger.atWarning().withCause(e).log(
"ignoring failure during validation of code owner config files in revision %s"
+ " (project = %s, branch = %s) because the validation was performed as dry-run",
receiveEvent.commit.getName(),
receiveEvent.getBranchNameKey().project(),
receiveEvent.getBranchNameKey().branch());
validationResult = Optional.empty();
}
}
if (!validationResult.isPresent()) {
return ImmutableList.of();
}
logger.atFine().log("validation result = %s", validationResult.get());
if (!metricRecordingDone) {
codeOwnerMetrics.countCodeOwnerConfigValidations.increment(
ValidationTrigger.COMMIT_RECEIVED,
validationResult.get().hasError()
? com.google.gerrit.plugins.codeowners.metrics.ValidationResult.REJECTED
: com.google.gerrit.plugins.codeowners.metrics.ValidationResult.PASSED,
codeOwnerConfigValidationPolicy.isDryRun());
}
return validationResult
.get()
.processForOnCommitReceived(codeOwnerConfigValidationPolicy.isDryRun());
}
}
@Override
public void onPreMerge(
Repository repository,
CodeReviewRevWalk revWalk,
CodeReviewCommit commit,
ProjectState projectState,
BranchNameKey branchNameKey,
PatchSet.Id patchSetId,
IdentifiedUser caller)
throws MergeValidationException {
try (TraceTimer traceTimer =
TraceContext.newTimer(
"Validate code owner config files on pre merge",
Metadata.builder()
.projectName(branchNameKey.project().get())
.commit(commit.name())
.branchName(branchNameKey.branch())
.username(caller.getLoggableName())
.patchSetId(patchSetId.get())
.build())) {
CodeOwnerConfigValidationPolicy codeOwnerConfigValidationPolicy =
codeOwnersPluginConfiguration
.getProjectConfig(branchNameKey.project())
.getCodeOwnerConfigValidationPolicyForSubmit(branchNameKey.branch());
logger.atFine().log("codeOwnerConfigValidationPolicy = %s", codeOwnerConfigValidationPolicy);
Optional<ValidationResult> validationResult;
if (!codeOwnerConfigValidationPolicy.runValidation()) {
validationResult =
Optional.of(
ValidationResult.create(
pluginName,
"skipping validation of code owner config files",
new CommitValidationMessage(
"code owners config validation is disabled", ValidationMessage.Type.HINT)));
} else {
try {
ChangeNotes changeNotes =
changeNotesFactory.create(projectState.getNameKey(), commit.change().getId());
PatchSet patchSet = patchSetUtil.get(changeNotes, patchSetId);
IdentifiedUser patchSetUploader = userFactory.create(patchSet.uploader());
validationResult =
validateCodeOwnerConfig(
branchNameKey,
commit,
patchSetUploader,
codeOwnerConfigValidationPolicy.isForced(),
/* pushOptions= */ ImmutableListMultimap.of());
} catch (RuntimeException e) {
codeOwnerMetrics.countCodeOwnerConfigValidations.increment(
ValidationTrigger.PRE_MERGE,
com.google.gerrit.plugins.codeowners.metrics.ValidationResult.FAILED,
codeOwnerConfigValidationPolicy.isDryRun());
if (!codeOwnerConfigValidationPolicy.isDryRun()) {
throw e;
}
// The validation was executed as dry-run and failures during the validation should not
// cause an error. Hence we swallow the exception here.
logger.atFine().withCause(e).log(
"ignoring failure during validation of code owner config files in revision %s"
+ " (project = %s, branch = %s) because the validation was performed as dry-run",
commit.name(), branchNameKey.project(), branchNameKey.branch());
validationResult = Optional.empty();
}
}
if (validationResult.isPresent()) {
logger.atFine().log("validation result = %s", validationResult.get());
codeOwnerMetrics.countCodeOwnerConfigValidations.increment(
ValidationTrigger.PRE_MERGE,
validationResult.get().hasError()
? com.google.gerrit.plugins.codeowners.metrics.ValidationResult.REJECTED
: com.google.gerrit.plugins.codeowners.metrics.ValidationResult.PASSED,
codeOwnerConfigValidationPolicy.isDryRun());
validationResult.get().processForOnPreMerge(codeOwnerConfigValidationPolicy.isDryRun());
}
}
}
/**
* Validates the code owner config files which are newly added or modified in the given commit.
*
* @param branchNameKey the project and branch that contains the provided commit or for which the
* commit is being pushed
* @param revCommit the commit for which newly added and modified code owner configs should be
* validated
* @param user user for which the code owner visibility checks should be performed
* @param force whether the validation should be done even if the code owners functionality is
* disabled for the branch
* @return the validation result, {@link Optional#empty()} if no validation is performed because
* the given commit doesn't contain newly added or modified code owner configs
*/
private Optional<ValidationResult> validateCodeOwnerConfig(
BranchNameKey branchNameKey,
RevCommit revCommit,
IdentifiedUser user,
boolean force,
ImmutableListMultimap<String, String> pushOptions) {
CodeOwnersPluginProjectConfigSnapshot codeOwnersConfig =
codeOwnersPluginConfiguration.getProjectConfig(branchNameKey.project());
logger.atFine().log("force = %s", force);
if (!force && codeOwnersConfig.isDisabled(branchNameKey.branch())) {
return Optional.of(
ValidationResult.create(
pluginName,
"skipping validation of code owner config files",
new CommitValidationMessage(
"code-owners functionality is disabled", ValidationMessage.Type.HINT)));
}
try {
if (skipCodeOwnerConfigValidationPushOption.skipValidation(pushOptions)) {
logger.atFine().log("skip validation requested");
return Optional.of(
ValidationResult.create(
pluginName,
"skipping validation of code owner config files",
new CommitValidationMessage(
String.format(
"the validation is skipped due to the --%s~%s push option",
pluginName, SkipCodeOwnerConfigValidationPushOption.NAME),
ValidationMessage.Type.HINT)));
}
} catch (AuthException e) {
logger.atFine().withCause(e).log("Not allowed to skip code owner config validation");
return Optional.of(
ValidationResult.create(
pluginName,
"skipping code owner config validation not allowed",
new CommitValidationMessage(e.getMessage(), ValidationMessage.Type.ERROR)));
} catch (SkipCodeOwnerConfigValidationPushOption.InvalidValueException e) {
logger.atFine().log(e.getMessage());
return Optional.of(
ValidationResult.create(
pluginName,
"invalid push option",
new CommitValidationMessage(e.getMessage(), ValidationMessage.Type.ERROR)));
}
if (codeOwnersConfig.areCodeOwnerConfigsReadOnly()) {
return Optional.of(
ValidationResult.create(
pluginName,
"modifying code owner config files not allowed",
new CommitValidationMessage(
"code owner config files are configured to be read-only",
ValidationMessage.Type.ERROR)));
}
try {
CodeOwnerBackend codeOwnerBackend = codeOwnersConfig.getBackend(branchNameKey.branch());
// For merge commits, always do the comparison against the destination branch
// (MergeCommitStrategy.ALL_CHANGED_FILES). Doing the comparison against the auto-merge
// (MergeCommitStrategy.FILES_WITH_CONFLICT_RESOLUTION) is not possible because loading the
// auto-merge cannot reuse the rev walk that can see newly created merge commits and hence
// trying to get the auto merge would fail with a missing object exception. This is why we
// use MergeCommitStrategy.ALL_CHANGED_FILES here even if
// MergeCommitStrategy.FILES_WITH_CONFLICT_RESOLUTION is configured.
ImmutableList<ChangedFile> modifiedCodeOwnerConfigFiles =
changedFiles
.getFromDiffCache(
branchNameKey.project(), revCommit, MergeCommitStrategy.ALL_CHANGED_FILES)
.stream()
// filter out deletions (files without new path)
.filter(changedFile -> changedFile.newPath().isPresent())
// filter out non code owner config files
.filter(
changedFile ->
codeOwnerBackend.isCodeOwnerConfigFile(
branchNameKey.project(),
Paths.get(changedFile.newPath().get().toString())
.getFileName()
.toString()))
.collect(toImmutableList());
if (modifiedCodeOwnerConfigFiles.isEmpty()) {
return Optional.empty();
}
// validate the code owner config files
return Optional.of(
ValidationResult.create(
pluginName,
modifiedCodeOwnerConfigFiles.stream()
.flatMap(
changedFile ->
validateCodeOwnerConfig(
user, codeOwnerBackend, branchNameKey, changedFile, revCommit))));
} catch (InvalidPluginConfigurationException e) {
// If the code-owners plugin configuration is invalid we cannot get the code owners backend
// and hence we are not able to detect and validate code owner config files. Instead of
// failing in this case (which would block all change uploads) we only log a warning and
// accept that it's possible to add invalid code owner configs while the plugin configuration
// is invalid.
logger.atWarning().log(
String.format(
"cannot validate code owner config files due to invalid code-owners plugin"
+ " configuration: %s",
e.getMessage()));
return Optional.of(
ValidationResult.create(
pluginName,
"skipping validation of code owner config files",
new CommitValidationMessage(
"code-owners plugin configuration is invalid,"
+ " cannot validate code owner config files",
ValidationMessage.Type.WARNING)));
} catch (IOException | DiffNotAvailableException e) {
String errorMessage =
String.format(
"failed to validate code owner config files in revision %s"
+ " (project = %s, branch = %s)",
revCommit.getName(), branchNameKey.project(), branchNameKey.branch());
logger.atSevere().withCause(e).log(errorMessage);
throw new CodeOwnersInternalServerErrorException(errorMessage, e);
}
}
/**
* Validates the specified code owner config and returns a stream of validation messages.
*
* @param user user for which the code owner visibility checks should be performed
* @param codeOwnerBackend the code owner backend from which the code owner config can be loaded
* @param branchNameKey the project and branch of the code owner config
* @param changedFile the changed file that represents the code owner config
* @param revCommit the commit from which the code owner config should be loaded
* @return a stream of validation messages that describe issues with the code owner config, an
* empty stream if there are no issues
*/
public Stream<CommitValidationMessage> validateCodeOwnerConfig(
IdentifiedUser user,
CodeOwnerBackend codeOwnerBackend,
BranchNameKey branchNameKey,
ChangedFile changedFile,
RevCommit revCommit) {
requireNonNull(user, "user");
requireNonNull(codeOwnerBackend, "codeOwnerBackend");
requireNonNull(branchNameKey, "branchNameKey");
requireNonNull(changedFile, "changedFile");
requireNonNull(revCommit, "revCommit");
if (!changedFile.newPath().isPresent()) {
// The code owner config file was deleted. Hence we do not need to do any validation.
return Stream.of();
}
CodeOwnerConfig codeOwnerConfig;
try {
// Load the code owner config. If the code owner config is not parsable this will fail with a
// InvalidConfigException (wrapped in a CodeOwnersInternalServerErrorException) that we handle
// below.
CodeOwnerConfig.Key codeOwnerConfigKey =
createCodeOwnerConfigKey(branchNameKey, changedFile.newPath().get());
codeOwnerConfig =
codeOwnerBackend
.getCodeOwnerConfig(codeOwnerConfigKey, revCommit)
// We already know that the path exists, so either the code owner config is
// successfully loaded (this case) or the loading fails with an exception because the
// code owner config is not parseable (catch block below), but it cannot happen that
// the code owner config is not found and an empty Optional is returned.
.orElseThrow(
() ->
new IllegalStateException(
String.format(
"code owner config %s not found in revision %s",
codeOwnerConfigKey, revCommit.name())));
} catch (CodeOwnersInternalServerErrorException codeOwnersInternalServerErrorException) {
// Loading the code owner config has failed.
Optional<InvalidCodeOwnerConfigException> invalidCodeOwnerConfigException =
getInvalidCodeOwnerConfigCause(codeOwnersInternalServerErrorException);
if (!invalidCodeOwnerConfigException.isPresent()) {
// Propagate any failure that is not related to the contents of the code owner config.
throw codeOwnersInternalServerErrorException;
}
// The exception was caused by a ConfigInvalidException. This means loading the code owner
// config failed because it is not parseable.
// Convert the message from the InvalidConfigException into a validation message and return
// it.
return Stream.of(
new CommitValidationMessage(
invalidCodeOwnerConfigException.get().getMessage(),
getValidationMessageTypeForParsingError(
codeOwnerBackend, branchNameKey, changedFile, revCommit)));
}
// The code owner config was successfully loaded and parsed.
// We only report new issues as errors. If the same issues already existed in the base version
// we just report them as warnings. To know which issues already existed in the base version
// we must load it, what we do here, and then run the validation on it.
Optional<CodeOwnerConfig> baseCodeOwnerConfig;
try {
baseCodeOwnerConfig =
getBaseCodeOwnerConfig(codeOwnerBackend, branchNameKey, changedFile, revCommit);
} catch (CodeOwnersInternalServerErrorException codeOwnersInternalServerErrorException) {
if (getInvalidCodeOwnerConfigCause(codeOwnersInternalServerErrorException).isPresent()) {
// The base code owner config is non-parseable. Since the update makes the code owner
// config parseable, it is a good update even if the code owner config still contains
// issues. Hence in this case we downgrade all validation errors in the new version to
// warnings so that the update is not blocked.
return validateCodeOwnerConfig(branchNameKey, user, codeOwnerBackend, codeOwnerConfig)
.map(CodeOwnerConfigValidator::downgradeErrorToWarning);
}
// Propagate any exception that was not caused by the content of the code owner config.
throw codeOwnersInternalServerErrorException;
}
// Validate the parsed code owner config.
if (baseCodeOwnerConfig.isPresent()) {
return validateCodeOwnerConfig(
branchNameKey, user, codeOwnerBackend, codeOwnerConfig, baseCodeOwnerConfig.get());
}
return validateCodeOwnerConfig(branchNameKey, user, codeOwnerBackend, codeOwnerConfig);
}
/**
* Create the key for a code owner config from a given file path.
*
* @param branchNameKey the project and branch of the code owner config
* @param filePath the file path of the code owner config
* @return the key of the code owner config
*/
private CodeOwnerConfig.Key createCodeOwnerConfigKey(BranchNameKey branchNameKey, Path filePath) {
Path folderPath =
filePath.getParent() != null
? JgitPath.of(filePath.getParent()).getAsAbsolutePath()
: Paths.get("/");
String fileName = filePath.getFileName().toString();
return CodeOwnerConfig.Key.create(branchNameKey, folderPath, fileName);
}
/**
* Loads and returns the base code owner config if it exists.
*
* <p>Throws a {@link ConfigInvalidException} (wrapped in a {@link
* CodeOwnersInternalServerErrorException} if the base code owner config exists, but is not
* parseable.
*
* @param codeOwnerBackend the code owner backend from which the base code owner config can be
* loaded
* @param branchNameKey the project and branch of the base code owner config
* @param changedFile the changed file of the code owner config that contains the path of the base
* code owner config as old path
* @param revCommit the commit of the code owner config for which the base code owner config
* should be loaded
* @return the loaded base code owner config, {@link Optional#empty()} if no base code owner
* config exists (e.g. if the code owner config is newly created)
*/
private Optional<CodeOwnerConfig> getBaseCodeOwnerConfig(
CodeOwnerBackend codeOwnerBackend,
BranchNameKey branchNameKey,
ChangedFile changedFile,
RevCommit revCommit) {
if (changedFile.oldPath().isPresent()) {
Optional<ObjectId> parentRevision = getParentRevision(branchNameKey.project(), revCommit);
if (parentRevision.isPresent()) {
CodeOwnerConfig.Key baseCodeOwnerConfigKey =
createCodeOwnerConfigKey(branchNameKey, changedFile.oldPath().get());
return codeOwnerBackend.getCodeOwnerConfig(baseCodeOwnerConfigKey, parentRevision.get());
}
}
return Optional.empty();
}
/**
* Returns the {@link com.google.gerrit.server.git.validators.ValidationMessage.Type} (FATAL or
* WARNING) that should be used for a parsing error of the code owner config file (specified as
* {@code changedFile}).
*
* <p>If {@link com.google.gerrit.server.git.validators.ValidationMessage.Type#FATAL} is returned
* the upload will be blocked, if {@link
* com.google.gerrit.server.git.validators.ValidationMessage.Type#WARNING} is returned the upload
* can succeed and the parsing error will only be shown as warning.
*
* <p>If a previous version of the code owner config exists and the previous version was also
* non-parseable, we want to allow the upload even if the new version is still non-parseable, as
* it is not making anything worse. Hence in this case the parsing error should be returned as
* {@link com.google.gerrit.server.git.validators.ValidationMessage.Type#WARNING}, whereas a new
* parsing error should be returned as {@link
* com.google.gerrit.server.git.validators.ValidationMessage.Type#FATAL}.
*
* @param codeOwnerBackend the code owner backend from which the code owner config can be loaded
* @param branchNameKey the project and branch of the code owner config
* @param changedFile the changed file that represents the code owner config
* @param revCommit the commit from which the code owner config should be loaded
* @return the {@link com.google.gerrit.server.git.validators.ValidationMessage.Type} (ERROR or
* WARNING) that should be used for parsing error of a code owner config file
*/
private ValidationMessage.Type getValidationMessageTypeForParsingError(
CodeOwnerBackend codeOwnerBackend,
BranchNameKey branchNameKey,
ChangedFile changedFile,
RevCommit revCommit) {
//
if (changedFile.oldPath().isPresent()) {
// A previous version of the code owner config exists.
ObjectId parentRevision =
getParentRevision(branchNameKey.project(), revCommit)
// Since there is an old path a parent revision must exist.
.orElseThrow(
() ->
new IllegalStateException(
String.format(
"parent revision for revision %s in project %s not found",
revCommit.name(), branchNameKey.project().get())));
try {
// Try to load the code owner config from the parent revision to see if it was parseable
// there.
CodeOwnerConfig.Key baseCodeOwnerConfigKey =
createCodeOwnerConfigKey(branchNameKey, changedFile.oldPath().get());
codeOwnerBackend.getCodeOwnerConfig(baseCodeOwnerConfigKey, parentRevision);
// The code owner config at the parent revision is parseable. This means the parsing error
// is introduced by the new commit and we should block uploading it, which we achieve by
// setting the validation message type to fatal.
return ValidationMessage.Type.FATAL;
} catch (CodeOwnersInternalServerErrorException codeOwnersInternalServerErrorException) {
// Loading the base code owner config has failed.
if (getInvalidCodeOwnerConfigCause(codeOwnersInternalServerErrorException).isPresent()) {
// The code owner config was already non-parseable before, hence we do not need to
// block the upload if the code owner config is still non-parseable.
// Using warning as type means that uploads are not blocked.
return ValidationMessage.Type.WARNING;
}
// Propagate any failure that is not related to the contents of the code owner config.
throw codeOwnersInternalServerErrorException;
}
}
// The code owner config is newly created. Hence the parsing error comes from the commit
// that is being pushed and we want to block it from uploading. To do this we set the
// validation message type to fatal.
return ValidationMessage.Type.FATAL;
}
/**
* Returns the first parent of the given revision.
*
* @param project the project that contains the revision
* @param revCommit the commit for which the first parent should be returned
* @return the first parent of the given revision, {@link Optional#empty()} if the given revision
* has no parent
*/
private Optional<ObjectId> getParentRevision(Project.NameKey project, RevCommit revCommit) {
if (revCommit.getParentCount() == 0) {
return Optional.empty();
}
RevCommit firstParent = revCommit.getParent(0);
logger.atFine().log(
"first parent of %s in %s is %s", revCommit.name(), project.get(), firstParent.name());
return Optional.of(firstParent);
}
/**
* Returns a copy of the given commit validation message with type warning if the type the given
* commit validation message is fatal or error. Otherwise it returns the given commit validation
* message unchanged.
*/
private static CommitValidationMessage downgradeErrorToWarning(
CommitValidationMessage commitValidationMessage) {
if (CommitValidationMessage.Type.FATAL.equals(commitValidationMessage.getType())
|| CommitValidationMessage.Type.ERROR.equals(commitValidationMessage.getType())) {
return new CommitValidationMessage(
commitValidationMessage.getMessage(), ValidationMessage.Type.WARNING);
}
return commitValidationMessage;
}
/**
* Validates the given code owner config and returns validation issues as stream.
*
* <p>Validation errors that exist in both code owner configs are returned as warning (because
* they are not newly introduced by the given code owner config).
*
* @param branchNameKey the branch and the project
* @param user user for which the code owner visibility checks should be performed
* @param codeOwnerBackend the code owner backend from which the code owner configs were loaded
* @param codeOwnerConfig the code owner config that should be validated
* @param baseCodeOwnerConfig the base code owner config
* @return a stream of validation messages that describe issues with the code owner config, an
* empty stream if there are no issues
*/
private Stream<CommitValidationMessage> validateCodeOwnerConfig(
BranchNameKey branchNameKey,
IdentifiedUser user,
CodeOwnerBackend codeOwnerBackend,
CodeOwnerConfig codeOwnerConfig,
CodeOwnerConfig baseCodeOwnerConfig) {
requireNonNull(codeOwnerConfig, "codeOwnerConfig");
requireNonNull(baseCodeOwnerConfig, "baseCodeOwnerConfig");
ImmutableSet<CommitValidationMessage> issuesInBaseVersion =
validateCodeOwnerConfig(branchNameKey, user, codeOwnerBackend, baseCodeOwnerConfig)
.collect(toImmutableSet());
return validateCodeOwnerConfig(branchNameKey, user, codeOwnerBackend, codeOwnerConfig)
.map(
commitValidationMessage ->
issuesInBaseVersion.contains(commitValidationMessage)
? downgradeErrorToWarning(commitValidationMessage)
: commitValidationMessage);
}
/**
* Validates the given code owner config and returns validation issues as stream.
*
* @param branchNameKey the branch and the project
* @param user user for which the code owner visibility checks should be performed
* @param codeOwnerBackend the code owner backend from which the code owner config was loaded
* @param codeOwnerConfig the code owner config that should be validated
* @return a stream of validation messages that describe issues with the code owner config, an
* empty stream if there are no issues
*/
public Stream<CommitValidationMessage> validateCodeOwnerConfig(
BranchNameKey branchNameKey,
IdentifiedUser user,
CodeOwnerBackend codeOwnerBackend,
CodeOwnerConfig codeOwnerConfig) {
requireNonNull(codeOwnerConfig, "codeOwnerConfig");
return Streams.concat(
validateCodeOwnerReferences(
branchNameKey,
user,
codeOwnerBackend.getFilePath(codeOwnerConfig.key()),
codeOwnerConfig),
validateImports(
branchNameKey, codeOwnerBackend.getFilePath(codeOwnerConfig.key()), codeOwnerConfig));
}
/**
* Validates the code owner references of the given code owner config.
*
* @param branchNameKey the branch and the project
* @param user user for which the code owner visibility checks should be performed
* @param codeOwnerConfigFilePath the path of the code owner config file which contains the code
* owner references
* @param codeOwnerConfig the code owner config for which the code owner references should be
* validated
* @return a stream of validation messages that describe issues with the code owner references, an
* empty stream if there are no issues
*/
private Stream<CommitValidationMessage> validateCodeOwnerReferences(
BranchNameKey branchNameKey,
IdentifiedUser user,
Path codeOwnerConfigFilePath,
CodeOwnerConfig codeOwnerConfig) {
return codeOwnerConfig.codeOwnerSets().stream()
.flatMap(codeOwnerSet -> codeOwnerSet.codeOwners().stream())
.map(
codeOwnerReference ->
validateCodeOwnerReference(
branchNameKey, user, codeOwnerConfigFilePath, codeOwnerReference))
.filter(Optional::isPresent)
.map(Optional::get);
}
/**
* Validates a code owner reference.
*
* @param branchNameKey the branch and the project
* @param user user for which the code owner visibility checks should be performed
* @param codeOwnerConfigFilePath the path of the code owner config file which contains the code
* owner reference
* @param codeOwnerReference the code owner reference that should be validated.
* @return a validation message describing the issue with the code owner reference, {@link
* Optional#empty()} if there is no issue
*/
private Optional<CommitValidationMessage> validateCodeOwnerReference(
BranchNameKey branchNameKey,
IdentifiedUser user,
Path codeOwnerConfigFilePath,
CodeOwnerReference codeOwnerReference) {
CodeOwnerResolver codeOwnerResolver = codeOwnerResolverProvider.get().forUser(user);
if (!codeOwnerResolver.isEmailDomainAllowed(codeOwnerReference.email()).get()) {
return nonResolvableCodeOwner(
branchNameKey,
String.format(
"the domain of the code owner email '%s' in '%s' is not allowed for code owners",
codeOwnerReference.email(), codeOwnerConfigFilePath));
}
// Check if the code owner reference is resolvable.
if (codeOwnerResolver.isResolvable(codeOwnerReference)) {
// The code owner reference was successfully resolved to at least one code owner.
return Optional.empty();
}
// It was not possible to resolve the code owner reference. Possible reasons: no such account
// exists, the code owner is not visible, the email of the code owner is not visible (see
// CodeOwerResolver for details). We intentionally return the same generic message in all these
// cases so that uploaders cannot probe emails for existence (e.g. they cannot add an email and
// conclude from the error message whether the email exists).
return nonResolvableCodeOwner(
branchNameKey,
String.format(
"code owner email '%s' in '%s' cannot be resolved for %s",
codeOwnerReference.email(), codeOwnerConfigFilePath, user.getLoggableName()));
}
/**
* Validates the imports of the given code owner config.
*
* @param branchNameKey the branch and the project
* @param codeOwnerConfigFilePath the path of the code owner config file which contains the code
* owner config
* @param codeOwnerConfig the code owner config for which the imports should be validated
* @return a stream of validation messages that describe issues with the imports, an empty stream
* if there are no issues
*/
private Stream<CommitValidationMessage> validateImports(
BranchNameKey branchNameKey, Path codeOwnerConfigFilePath, CodeOwnerConfig codeOwnerConfig) {
try {
RevCommit codeOwnerConfigRevision;
try (Repository repo = repoManager.openRepository(branchNameKey.project());
RevWalk revWalk = new RevWalk(repo)) {
codeOwnerConfigRevision = revWalk.parseCommit(codeOwnerConfig.revision());
}
return Streams.concat(
codeOwnerConfig.imports().stream()
.map(
codeOwnerConfigReference ->
validateCodeOwnerConfigReference(
branchNameKey,
codeOwnerConfigFilePath,
codeOwnerConfig.key(),
codeOwnerConfigRevision,
CodeOwnerConfigImportType.GLOBAL,
codeOwnerConfigReference)),
codeOwnerConfig.codeOwnerSets().stream()
.flatMap(codeOwnerSet -> codeOwnerSet.imports().stream())
.map(
codeOwnerConfigReference ->
validateCodeOwnerConfigReference(
branchNameKey,
codeOwnerConfigFilePath,
codeOwnerConfig.key(),
codeOwnerConfigRevision,
CodeOwnerConfigImportType.PER_FILE,
codeOwnerConfigReference)))
.filter(Optional::isPresent)
.map(Optional::get);
} catch (IOException e) {
throw new CodeOwnersInternalServerErrorException(
String.format("Failed to validate imports for %s in ", codeOwnerConfig.key()), e);
}
}
/**
* Validates a code owner config reference.
*
* @param branchNameKey the branch and the project
* @param codeOwnerConfigFilePath the path of the code owner config file which contains the code
* owner config reference
* @param keyOfImportingCodeOwnerConfig key of the importing code owner config
* @param codeOwnerConfigRevision the commit from which the code owner config which contains the
* code owner config reference was loaded
* @param importType the type of the import
* @param codeOwnerConfigReference the code owner config reference that should be validated.
* @return a validation message describing the issue with the code owner config reference, {@link
* Optional#empty()} if there is no issue
*/
private Optional<CommitValidationMessage> validateCodeOwnerConfigReference(
BranchNameKey branchNameKey,
Path codeOwnerConfigFilePath,
CodeOwnerConfig.Key keyOfImportingCodeOwnerConfig,
RevCommit codeOwnerConfigRevision,
CodeOwnerConfigImportType importType,
CodeOwnerConfigReference codeOwnerConfigReference) {
CodeOwnerConfig.Key keyOfImportedCodeOwnerConfig =
PathCodeOwners.createKeyForImportedCodeOwnerConfig(
keyOfImportingCodeOwnerConfig, codeOwnerConfigReference);
if (isSelfImport(keyOfImportingCodeOwnerConfig, keyOfImportedCodeOwnerConfig)) {
return nonResolvableImport(
importType,
codeOwnerConfigFilePath,
"code owner config imports itself",
ValidationMessage.Type.WARNING);
}
Optional<ProjectState> projectState = projectCache.get(keyOfImportedCodeOwnerConfig.project());
if (!projectState.isPresent() || !isProjectReadable(keyOfImportedCodeOwnerConfig)) {
// we intentionally use the same error message for non-existing and non-readable projects so
// that uploaders cannot probe for the existence of projects (e.g. deduce from the error
// message whether a project exists)
return nonResolvableImport(
codeOwnerConfigRevision,
branchNameKey,
importType,
codeOwnerConfigReference,
codeOwnerConfigFilePath,
String.format("project '%s' not found", keyOfImportedCodeOwnerConfig.project().get()));
}
if (!projectState.get().statePermitsRead()) {
return nonResolvableImport(
codeOwnerConfigRevision,
branchNameKey,
importType,
codeOwnerConfigReference,
codeOwnerConfigFilePath,
String.format(
"project '%s' has state '%s' that doesn't permit read",
keyOfImportedCodeOwnerConfig.project().get(),
projectState.get().getProject().getState().name()));
}
Optional<ObjectId> revision =
getRevision(
keyOfImportingCodeOwnerConfig, codeOwnerConfigRevision, keyOfImportedCodeOwnerConfig);
if (!revision.isPresent() || !isBranchReadable(keyOfImportedCodeOwnerConfig)) {
// we intentionally use the same error message for non-existing and non-readable branches so
// that uploaders cannot probe for the existence of branches (e.g. deduce from the error
// message whether a branch exists)
return nonResolvableImport(
codeOwnerConfigRevision,
branchNameKey,
importType,
codeOwnerConfigReference,
codeOwnerConfigFilePath,
String.format(
"branch '%s' not found in project '%s'",
keyOfImportedCodeOwnerConfig.shortBranchName(),
keyOfImportedCodeOwnerConfig.project().get()));
}
CodeOwnerBackend codeOwnerBackend =
codeOwnersPluginConfiguration
.getProjectConfig(keyOfImportedCodeOwnerConfig.project())
.getBackend(keyOfImportedCodeOwnerConfig.branchNameKey().branch());
if (!codeOwnerBackend.isCodeOwnerConfigFile(
keyOfImportedCodeOwnerConfig.project(), codeOwnerConfigReference.fileName())) {
return nonResolvableImport(
codeOwnerConfigRevision,
branchNameKey,
importType,
codeOwnerConfigReference,
codeOwnerConfigFilePath,
String.format(
"'%s' is not a code owner config file", codeOwnerConfigReference.filePath()));
}
try {
// If a code owner config is imported from the same project, we must use the provided rev
// walk, otherwise the revision may not be visible yet and trying to load a code owner config
// from it could fail with MissingObjectException.
Optional<CodeOwnerConfig> importedCodeOwnerConfig =
keyOfImportedCodeOwnerConfig.project().equals(branchNameKey.project())
? codeOwnerBackend.getCodeOwnerConfig(keyOfImportedCodeOwnerConfig, revision.get())
: codeOwnerBackend.getCodeOwnerConfig(keyOfImportedCodeOwnerConfig, revision.get());
if (!importedCodeOwnerConfig.isPresent()) {
return nonResolvableImport(
codeOwnerConfigRevision,
branchNameKey,
importType,
codeOwnerConfigReference,
codeOwnerConfigFilePath,
String.format(
"'%s' does not exist (project = %s, branch = %s, revision = %s)",
codeOwnerConfigReference.filePath(),
keyOfImportedCodeOwnerConfig.branchNameKey().project().get(),
keyOfImportedCodeOwnerConfig.branchNameKey().shortName(),
revision.get().name()));
}
} catch (CodeOwnersInternalServerErrorException codeOwnersInternalServerErrorException) {
if (getInvalidCodeOwnerConfigCause(codeOwnersInternalServerErrorException).isPresent()) {
// The imported code owner config is non-parseable.
return nonResolvableImport(
codeOwnerConfigRevision,
branchNameKey,
importType,
codeOwnerConfigReference,
codeOwnerConfigFilePath,
String.format(
"'%s' is not parseable (project = %s, branch = %s)",
codeOwnerConfigReference.filePath(),
keyOfImportedCodeOwnerConfig.branchNameKey().project().get(),
keyOfImportedCodeOwnerConfig.branchNameKey().shortName()));
}
// Propagate any exception that was not caused by the content of the code owner config.
throw codeOwnersInternalServerErrorException;
}
// no issue found
return Optional.empty();
}
/** Whether the importing code owner config is the same as the imported code owner config. */
private boolean isSelfImport(
CodeOwnerConfig.Key keyOfImportingCodeOwnerConfig,
CodeOwnerConfig.Key keyOfImportedCodeOwnerConfig) {
return keyOfImportingCodeOwnerConfig.project().equals(keyOfImportedCodeOwnerConfig.project())
&& keyOfImportingCodeOwnerConfig.ref().equals(keyOfImportedCodeOwnerConfig.ref())
&& codeOwnersPluginConfiguration
.getProjectConfig(keyOfImportingCodeOwnerConfig.project())
.getBackend(keyOfImportingCodeOwnerConfig.branchNameKey().branch())
.getFilePath(keyOfImportingCodeOwnerConfig)
.equals(
codeOwnersPluginConfiguration
.getProjectConfig(keyOfImportedCodeOwnerConfig.project())
.getBackend(keyOfImportedCodeOwnerConfig.branchNameKey().branch())
.getFilePath(keyOfImportedCodeOwnerConfig));
}
private boolean isProjectReadable(CodeOwnerConfig.Key keyOfImportedCodeOwnerConfig) {
try {
return permissionBackend
.currentUser()
.project(keyOfImportedCodeOwnerConfig.project())
.test(ProjectPermission.ACCESS);
} catch (PermissionBackendException e) {
throw new CodeOwnersInternalServerErrorException(
"failed to check read permission for project of imported code owner config", e);
}
}
private boolean isBranchReadable(CodeOwnerConfig.Key keyOfImportedCodeOwnerConfig) {
try {
return permissionBackend
.currentUser()
.project(keyOfImportedCodeOwnerConfig.project())
.ref(keyOfImportedCodeOwnerConfig.ref())
.test(RefPermission.READ);
} catch (PermissionBackendException e) {
throw new CodeOwnersInternalServerErrorException(
"failed to check read permission for branch of imported code owner config", e);
}
}
private Optional<ObjectId> getRevision(
CodeOwnerConfig.Key keyOfImportingCodeOwnerConfig,
ObjectId codeOwnerConfigRevision,
CodeOwnerConfig.Key keyOfImportedCodeOwnerConfig) {
if (keyOfImportingCodeOwnerConfig
.branchNameKey()
.equals(keyOfImportedCodeOwnerConfig.branchNameKey())) {
// load the imported code owner config from the same revision from which the importing code
// owner config was loaded
return Optional.of(codeOwnerConfigRevision);
}
try (Repository repo = repoManager.openRepository(keyOfImportedCodeOwnerConfig.project())) {
return Optional.ofNullable(repo.exactRef(keyOfImportedCodeOwnerConfig.ref()))
.map(Ref::getObjectId);
} catch (IOException e) {
throw new CodeOwnersInternalServerErrorException(
"failed to read revision of import code owner config", e);
}
}
private Optional<CommitValidationMessage> nonResolvableImport(
RevCommit codeOwnerConfigRevision,
BranchNameKey branchNameKey,
CodeOwnerConfigImportType importType,
CodeOwnerConfigReference codeOwnerConfigReference,
Path codeOwnerConfigFilePath,
String message) {
ValidationMessage.Type validationMessageType;
if (codeOwnerConfigRevision.getParentCount() > 1
&& !codeOwnerConfigReference.branch().isPresent()
&& codeOwnerConfigReference.project().isPresent()
&& !codeOwnerConfigReference.project().get().equals(branchNameKey.project())) {
// For merge commits, imports from other projects, that implicitly assume the same branch as
// the importing code owner config, should not be rejected if they cannot be resolved. Hence
// issues with them are always reported as warnings (rather than errors which would cause a
// rejection).
//
// We do not reject such non-resolvable imports because that can require landing merge
// commits in a certain order or even make the landing of merge commits impossible.
//
// Example 1:
// 1. project A adds foo/OWNERS
// 2. project B imports A:foo/OWNERS
// => If these changes should be merged into another branch (e.g. a release branch) the
// validation of the code owner config files only succeeds if the merges are done in the
// correct order (1. do merge for project A, 2. do merge for project B). If the merges are
// done in the opposite order (1. do merge for project B, 2. do merge for project A) the code
// owner config file validation for the merge in project B would fail since A:foo/OWNERS
// doesn't exist in the target branch yet.
//
// Example 2:
// 1. project A adds foo/OWNERS
// 2. project B imports A:foo/OWNERS
// 3. project B adds bar/OWNERS
// 4. project A imports B:bar/OWNERS
// => If the merge for project A is done first the code owner config file validation would
// fail because B:bar/OWNERS doesn't exist yet. If the merge for project B is done first the
// code owner config file validation would fail because A:foo/OWNERS doesn't exist yet.
validationMessageType = ValidationMessage.Type.WARNING;
} else {
validationMessageType =
codeOwnersPluginConfiguration
.getProjectConfig(branchNameKey.project())
.rejectNonResolvableImports(branchNameKey.branch())
? ValidationMessage.Type.ERROR
: ValidationMessage.Type.WARNING;
}
return nonResolvableImport(importType, codeOwnerConfigFilePath, message, validationMessageType);
}
private Optional<CommitValidationMessage> nonResolvableImport(
CodeOwnerConfigImportType importType,
Path codeOwnerConfigFilePath,
String message,
ValidationMessage.Type validationMessageType) {
return Optional.of(
new CommitValidationMessage(
String.format(
"invalid %s import in '%s': %s",
importType.getType(), codeOwnerConfigFilePath, message),
validationMessageType));
}
private Optional<CommitValidationMessage> nonResolvableCodeOwner(
BranchNameKey branchNameKey, String message) {
return Optional.of(
new CommitValidationMessage(
message,
codeOwnersPluginConfiguration
.getProjectConfig(branchNameKey.project())
.rejectNonResolvableCodeOwners(branchNameKey.branch())
? ValidationMessage.Type.ERROR
: ValidationMessage.Type.WARNING));
}
/** The result of validating code owner config files. */
@AutoValue
public abstract static class ValidationResult {
private static final String NO_ISSUES_MSG =
"code owner config files validated, no issues found";
private static final String INVALID_MSG = "invalid code owner config files";
abstract String pluginName();
abstract String summaryMessage();
abstract ImmutableList<CommitValidationMessage> validationMessages();
static ValidationResult create(
String pluginName, String summaryMessage, CommitValidationMessage commitValidationMessage) {
return new AutoValue_CodeOwnerConfigValidator_ValidationResult(
pluginName, summaryMessage, ImmutableList.of(commitValidationMessage));
}
static ValidationResult create(
String pluginName, Stream<CommitValidationMessage> validationMessagesStream) {
ImmutableList<CommitValidationMessage> validationMessages =
validationMessagesStream.collect(toImmutableList());
return new AutoValue_CodeOwnerConfigValidator_ValidationResult(
pluginName,
validationMessages.isEmpty() ? NO_ISSUES_MSG : INVALID_MSG,
validationMessages);
}
/**
* Processes the validation messages for a validation that is done when a commit is received
* (e.g. on push).
*
* <p>Throws a {@link CommitValidationException} if there are errors to make the upload fail.
*
* <p>If there are no errors the validation messages are returned so that they can be sent to
* the client without causing the upload to fail.
*/
List<CommitValidationMessage> processForOnCommitReceived(boolean dryRun)
throws CommitValidationException {
if (!dryRun && hasError()) {
throw new CommitValidationException(
withPluginName(summaryMessage()), withPluginName(validationMessages()));
}
return validationMessagesWithIncludedSummaryMessage();
}
/**
* Processes the validation messages for a validation that is done on pre-merge (aka on submit).
*
* <p>Throws a {@link MergeValidationException} if there are errors to make the submit fail.
*
* <p>If there are no errors the validation messages are logged on fine level so that they show
* up in a trace. Returning the message to the user without failing the submit is not possible.
*/
void processForOnPreMerge(boolean dryRun) throws MergeValidationException {
if (!dryRun && hasError()) {
throw new MergeValidationException(getMessage(validationMessages()));
}
if (!validationMessages().isEmpty()) {
logger.atFine().log(
"submitting changes to code owner config files with the following messages: %s",
validationMessagesWithIncludedSummaryMessage());
} else {
logger.atFine().log("submitting changes to code owner config files, no issues found");
}
}
/** Checks whether any of the validation messages is an error. */
public boolean hasError() {
return validationMessages().stream()
.anyMatch(
validationMessage ->
ValidationMessage.Type.FATAL.equals(validationMessage.getType())
|| ValidationMessage.Type.ERROR.equals(validationMessage.getType()));
}
private ImmutableList<CommitValidationMessage> validationMessagesWithIncludedSummaryMessage() {
return ImmutableList.<CommitValidationMessage>builder()
.add(
new CommitValidationMessage(
withPluginName(summaryMessage()), getValidationMessageTypeForSummaryMessage()))
.addAll(withPluginName(validationMessages()))
.build();
}
/**
* Gets the validation message type that should be used for the summary message.
*
* <p>The following validation message type will be returned:
*
* <ul>
* <li>FATAL: if any of the validation message has type fatal
* <li>ERROR: if any of the validation message has type error
* <li>WARNING: if any of the validation message has type warning and none has type error
* <li>HINT: otherwise
* </ul>
*/
private ValidationMessage.Type getValidationMessageTypeForSummaryMessage() {
ValidationMessage.Type validationMessageType = ValidationMessage.Type.HINT;
if (!validationMessages().isEmpty()) {
for (CommitValidationMessage validationMessage : validationMessages()) {
if (ValidationMessage.Type.FATAL.equals(validationMessage.getType())) {
validationMessageType = ValidationMessage.Type.FATAL;
break;
}
if (ValidationMessage.Type.ERROR.equals(validationMessage.getType())) {
validationMessageType = ValidationMessage.Type.ERROR;
break;
}
if (ValidationMessage.Type.WARNING.equals(validationMessage.getType())) {
validationMessageType = ValidationMessage.Type.WARNING;
}
}
}
return validationMessageType;
}
/**
* Composes a single message out of the given validation messages.
*
* <p>Expects that at least 1 validation message is provided.
*
* @param validationMessages the validation messages
* @return the composed message
*/
private String getMessage(List<CommitValidationMessage> validationMessages) {
checkState(!validationMessages.isEmpty(), "expected at least 1 validation message");
StringBuilder msgBuilder = new StringBuilder(withPluginName(summaryMessage())).append(":");
for (CommitValidationMessage msg : validationMessages) {
msgBuilder
.append("\n ")
.append(msg.getType().name())
.append(": ")
.append(msg.getMessage());
}
return msgBuilder.toString();
}
private String withPluginName(String message) {
return "[" + pluginName() + "] " + message;
}
private ImmutableList<CommitValidationMessage> withPluginName(
ImmutableList<CommitValidationMessage> validationMessages) {
return validationMessages.stream()
.map(msg -> new CommitValidationMessage(withPluginName(msg.getMessage()), msg.getType()))
.collect(toImmutableList());
}
}
}