blob: 90fd87854c7d3299046ad58b4f286f50c48b6020 [file] [log] [blame]
// Copyright (C) 2020 The Android Open Source Project
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package com.google.gerrit.plugins.codeowners.backend;
import static com.google.common.base.Preconditions.checkState;
import static java.util.Objects.requireNonNull;
import com.google.common.annotations.VisibleForTesting;
import com.google.common.base.Strings;
import com.google.gerrit.metrics.Timer0;
import com.google.gerrit.metrics.Timer1;
import com.google.gerrit.plugins.codeowners.metrics.CodeOwnerMetrics;
import com.google.gerrit.plugins.codeowners.util.JgitPath;
import com.google.gerrit.server.git.meta.MetaDataUpdate;
import com.google.gerrit.server.git.meta.VersionedMetaData;
import com.google.inject.Inject;
import java.io.IOException;
import java.util.Optional;
import org.eclipse.jgit.errors.ConfigInvalidException;
import org.eclipse.jgit.lib.CommitBuilder;
import org.eclipse.jgit.lib.ObjectId;
import org.eclipse.jgit.lib.Repository;
import org.eclipse.jgit.revwalk.RevCommit;
import org.eclipse.jgit.revwalk.RevWalk;
import org.eclipse.jgit.treewalk.TreeWalk;
/**
* A representation of a code owner config that is stored as an {@code OWNERS} file in a source
* branch.
*
* <p>For reading code owner configs or creating/updating them, refer to {@link Factory#load(String,
* CodeOwnerConfigParser, RevWalk, ObjectId, CodeOwnerConfig.Key)} and {@link
* Factory#loadCurrent(String, CodeOwnerConfigParser, Repository, CodeOwnerConfig.Key)}.
*
* <p><strong>Note:</strong> Any modification (code owner config creation or update) only becomes
* permanent (and hence written to repository) if {@link
* #commit(com.google.gerrit.server.git.meta.MetaDataUpdate)} is called.
*/
@VisibleForTesting
public class CodeOwnerConfigFile extends VersionedMetaData {
public static class Factory {
private final CodeOwnerMetrics codeOwnerMetrics;
@Inject
Factory(CodeOwnerMetrics codeOwnerMetrics) {
this.codeOwnerMetrics = codeOwnerMetrics;
}
/**
* Creates a {@link CodeOwnerConfigFile} for a code owner config.
*
* <p>The code owner config is automatically loaded within this method and can be accessed via
* {@link #getLoadedCodeOwnerConfig()}.
*
* <p>It's safe to call this method for non-existing code owner configs. In that case, {@link
* #getLoadedCodeOwnerConfig()} won't return any code owner config. Thus, the existence of a
* code owner config can be easily tested.
*
* <p>The code owner config represented by the returned {@link CodeOwnerConfigFile} can be
* created/updated by setting an {@link CodeOwnerConfigUpdate} via {@link
* #setCodeOwnerConfigUpdate(CodeOwnerConfigUpdate)} and committing the {@link
* CodeOwnerConfigUpdate} via {@link #commit(com.google.gerrit.server.git.meta.MetaDataUpdate)}.
*
* @param defaultFileName the name of the code owner configuration files that should be used if
* none is specified in the code owner config key
* @param codeOwnerConfigParser the parser that should be used to parse code owner config files
* @param revWalk the revWalk that should be used to load the revision
* @param revision the branch revision from which the code owner config file should be loaded
* @param codeOwnerConfigKey the key of the code owner config
* @return a {@link CodeOwnerConfigFile} for the code owner config with the specified key
* @throws IOException if the repository can't be accessed for some reason
* @throws ConfigInvalidException if the code owner config exists but can't be read due to an
* invalid format
*/
public CodeOwnerConfigFile load(
String defaultFileName,
CodeOwnerConfigParser codeOwnerConfigParser,
RevWalk revWalk,
ObjectId revision,
CodeOwnerConfig.Key codeOwnerConfigKey)
throws IOException, ConfigInvalidException {
requireNonNull(defaultFileName, "defaultFileName");
requireNonNull(codeOwnerConfigParser, "codeOwnerConfigParser");
requireNonNull(revWalk, "revWalk");
requireNonNull(revision, "revision");
requireNonNull(codeOwnerConfigKey, "codeOwnerConfigKey");
CodeOwnerConfigFile codeOwnerConfigFile =
new CodeOwnerConfigFile(
codeOwnerMetrics, defaultFileName, codeOwnerConfigParser, codeOwnerConfigKey);
codeOwnerConfigFile.load(codeOwnerConfigKey.project(), revWalk, revision);
return codeOwnerConfigFile;
}
/**
* Creates a {@link CodeOwnerConfigFile} for a code owner config from the current revision in
* the branch.
*
* @param defaultFileName the name of the code owner configuration files that should be used if
* none is specified in the code owner config key
* @param codeOwnerConfigParser the parser that should be used to parse code owner config files
* @param repository the repository in which the code owner config is stored
* @param codeOwnerConfigKey the key of the code owner config
* @return a {@link CodeOwnerConfigFile} for the code owner config with the specified key
* @throws IOException if the repository can't be accessed for some reason
* @throws ConfigInvalidException if the code owner config exists but can't be read due to an
* invalid format
* @see #load(String, CodeOwnerConfigParser, RevWalk, ObjectId, CodeOwnerConfig.Key)
*/
public CodeOwnerConfigFile loadCurrent(
String defaultFileName,
CodeOwnerConfigParser codeOwnerConfigParser,
Repository repository,
CodeOwnerConfig.Key codeOwnerConfigKey)
throws IOException, ConfigInvalidException {
requireNonNull(defaultFileName, "defaultFileName");
requireNonNull(codeOwnerConfigParser, "codeOwnerConfigParser");
requireNonNull(repository, "repository");
requireNonNull(codeOwnerConfigKey, "codeOwnerConfigKey");
CodeOwnerConfigFile codeOwnerConfigFile =
new CodeOwnerConfigFile(
codeOwnerMetrics, defaultFileName, codeOwnerConfigParser, codeOwnerConfigKey);
codeOwnerConfigFile.load(codeOwnerConfigKey.project(), repository);
return codeOwnerConfigFile;
}
}
private final CodeOwnerMetrics codeOwnerMetrics;
private final String defaultFileName;
private final CodeOwnerConfigParser codeOwnerConfigParser;
private final CodeOwnerConfig.Key codeOwnerConfigKey;
private boolean isLoaded = false;
private Optional<CodeOwnerConfig> loadedCodeOwnersConfig = Optional.empty();
private Optional<CodeOwnerConfigUpdate> codeOwnerConfigUpdate = Optional.empty();
private CodeOwnerConfigFile(
CodeOwnerMetrics codeOwnerMetrics,
String defaultFileName,
CodeOwnerConfigParser codeOwnerConfigParser,
CodeOwnerConfig.Key codeOwnerConfigKey) {
this.codeOwnerMetrics = codeOwnerMetrics;
this.defaultFileName = defaultFileName;
this.codeOwnerConfigParser = codeOwnerConfigParser;
this.codeOwnerConfigKey = codeOwnerConfigKey;
}
/**
* Returns the loaded code owner config if it exists.
*
* @return the loaded code owner config, or {@link Optional#empty()} if the code owner config
* doesn't exist
*/
public Optional<CodeOwnerConfig> getLoadedCodeOwnerConfig() {
checkLoaded();
// If a loaded code owner config is present, update its revision if it is outdated.
if (loadedCodeOwnersConfig.isPresent()
&& (!loadedCodeOwnersConfig.get().revision().equals(revision))) {
loadedCodeOwnersConfig =
Optional.of(loadedCodeOwnersConfig.get().toBuilder().setRevision(revision).build());
}
return loadedCodeOwnersConfig;
}
/**
* Specifies how the current code owner config should be updated.
*
* <p>If the code owner config is newly created, the {@link CodeOwnerConfigUpdate} can be used to
* specify optional properties.
*
* <p>If the update leads to an empty code owner config, the code owner config file is deleted.
*
* <p><strong>Note:</strong> This method doesn't perform the update. It only contains the
* instructions for the update. To apply the update for real, call {@link
* #commit(com.google.gerrit.server.git.meta.MetaDataUpdate)} on this {@link CodeOwnerConfigFile}.
*
* @param codeOwnerConfigUpdate an {@code CodeOwnerConfigUpdate} outlining the modifications which
* should be applied
* @return this {@code CodeOwnerConfigFile} instance to allow chaining calls
*/
public CodeOwnerConfigFile setCodeOwnerConfigUpdate(CodeOwnerConfigUpdate codeOwnerConfigUpdate) {
this.codeOwnerConfigUpdate = Optional.of(codeOwnerConfigUpdate);
return this;
}
@Override
protected String getRefName() {
return codeOwnerConfigKey.branchNameKey().branch();
}
@Override
protected byte[] readFile(String fileName) throws IOException {
try (Timer0.Context ctx = codeOwnerMetrics.readCodeOwnerConfig.start()) {
return super.readFile(fileName);
}
}
@Override
protected void onLoad() throws IOException, ConfigInvalidException {
if (revision != null) {
String codeOwnerConfigFilePath =
JgitPath.of(codeOwnerConfigKey.filePath(defaultFileName)).get();
Optional<String> codeOwnerConfigFileContent = getFileIfItExists(codeOwnerConfigFilePath);
if (codeOwnerConfigFileContent.isPresent()) {
try (Timer1.Context<String> ctx =
codeOwnerMetrics.parseCodeOwnerConfig.start(
codeOwnerConfigParser.getClass().getSimpleName())) {
loadedCodeOwnersConfig =
Optional.of(
codeOwnerConfigParser.parse(
revision, codeOwnerConfigKey, codeOwnerConfigFileContent.get()));
} catch (CodeOwnerConfigParseException e) {
throw new InvalidCodeOwnerConfigException(
e.getFullMessage(defaultFileName),
projectName,
getRefName(),
codeOwnerConfigFilePath,
e);
}
}
}
isLoaded = true;
}
/**
* Loads the file with the given path and returns the file content if the file exists.
*
* @param filePath the path of the file that should be loaded
* @return the content of the file if it exists, otherwise {@link Optional#empty()}.
*/
private Optional<String> getFileIfItExists(String filePath) throws IOException {
try (TreeWalk tw = TreeWalk.forPath(rw.getObjectReader(), filePath, revision.getTree())) {
if (tw != null) {
return Optional.of(readUTF8(filePath));
}
}
return Optional.empty();
}
@Override
public RevCommit commit(MetaDataUpdate update) throws IOException {
// Reject the creation of a code owner config if the branch doesn't exist.
checkState(
update.getRepository().exactRef(getRefName()) != null,
"branch %s does not exist",
getRefName());
return super.commit(update);
}
@Override
protected boolean onSave(CommitBuilder commit) throws IOException, ConfigInvalidException {
checkLoaded();
if (!codeOwnerConfigUpdate.isPresent()) {
// Code owner config was neither created nor changed. -> A new commit isn't necessary.
return false;
}
// Update the code owner config.
CodeOwnerConfig originalCodeOwnerConfig =
loadedCodeOwnersConfig.orElse(
CodeOwnerConfig.builder(codeOwnerConfigKey, revision).build());
CodeOwnerConfig updatedCodeOwnerConfig =
updateCodeOwnerConfig(originalCodeOwnerConfig, codeOwnerConfigUpdate.get());
// Do not create a new commit if the code owner config didn't change.
if (updatedCodeOwnerConfig.equals(originalCodeOwnerConfig)) {
return false;
}
// Compute the new content of the code owner config file.
String codeOwnerConfigFileContent =
codeOwnerConfigParser.formatAsString(updatedCodeOwnerConfig);
// Save the new code owner config.
saveUTF8(
JgitPath.of(codeOwnerConfigKey.filePath(defaultFileName)).get(),
codeOwnerConfigFileContent);
// If the file content is empty, the update led to a deletion of the code owner config file.
boolean isDeleted = codeOwnerConfigFileContent.isEmpty();
// Set a commit message if none was set yet.
if (Strings.isNullOrEmpty(commit.getMessage())) {
commit.setMessage(
String.format(
"%s code owner config",
loadedCodeOwnersConfig.isPresent() ? (isDeleted ? "Delete" : "Update") : "Create"));
}
loadedCodeOwnersConfig = isDeleted ? Optional.empty() : Optional.of(updatedCodeOwnerConfig);
codeOwnerConfigUpdate = Optional.empty();
return true;
}
private static CodeOwnerConfig updateCodeOwnerConfig(
CodeOwnerConfig codeOwnerConfig, CodeOwnerConfigUpdate codeOwnerConfigUpdate) {
CodeOwnerConfig.Builder codeOwnerConfigBuilder = codeOwnerConfig.toBuilder();
codeOwnerConfigUpdate
.ignoreParentCodeOwners()
.ifPresent(codeOwnerConfigBuilder::setIgnoreParentCodeOwners);
codeOwnerConfigBuilder.setCodeOwnerSets(
codeOwnerConfigUpdate
.codeOwnerSetsModification()
.apply(codeOwnerConfig.codeOwnerSetsAsList()));
codeOwnerConfigBuilder.setImports(
codeOwnerConfigUpdate.importsModification().apply(codeOwnerConfig.importsAsList()));
return codeOwnerConfigBuilder.build();
}
private void checkLoaded() {
checkState(isLoaded, "Code owner config %s not loaded yet", codeOwnerConfigKey);
}
}