blob: 927d3fd156867881b5e3b60ffa15d3836809a918 [file] [log] [blame]
// Copyright (C) 2022 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.server.schema;
import static java.util.stream.Collectors.joining;
import com.google.common.annotations.VisibleForTesting;
import com.google.common.base.Splitter;
import com.google.common.base.Strings;
import com.google.common.collect.ImmutableList;
import com.google.common.primitives.Shorts;
import com.google.gerrit.entities.PermissionRule;
import com.google.gerrit.entities.Project;
import com.google.gerrit.entities.RefNames;
import com.google.gerrit.extensions.client.ChangeKind;
import com.google.gerrit.server.GerritPersonIdent;
import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
import com.google.gerrit.server.git.GitRepositoryManager;
import com.google.gerrit.server.git.meta.MetaDataUpdate;
import com.google.gerrit.server.git.meta.VersionedConfigFile;
import com.google.gerrit.server.project.ProjectConfig;
import com.google.inject.Inject;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import java.util.function.Consumer;
import org.eclipse.jgit.errors.ConfigInvalidException;
import org.eclipse.jgit.lib.Config;
import org.eclipse.jgit.lib.PersonIdent;
import org.eclipse.jgit.lib.Ref;
import org.eclipse.jgit.lib.Repository;
import org.eclipse.jgit.revwalk.RevCommit;
import org.eclipse.jgit.revwalk.RevWalk;
/**
* Migrates all label configurations of a project to copy conditions.
*
* <p>The label configuration in {@code project.config} controls under which conditions approvals
* should be copied to new patch sets:
*
* <ul>
* <li>old way: by setting boolean flags and copy values (fields {@code copyAnyScore}, {@code
* copyMinScore}, {@code copyMaxScore}, {@code copyAllScoresIfNoChange}, {@code
* copyAllScoresIfNoCodeChange}, {@code copyAllScoresOnMergeFirstParentUpdate}, {@code
* copyAllScoresOnTrivialRebase}, {@code copyAllScoresIfListOfFilesDidNotChange}, {@code
* copyValue})
* <li>new way: by setting a query as a copy condition (field {@code copyCondition})
* </ul>
*
* <p>This class updates all label configurations in the {@code project.config} of the given
* project:
*
* <ul>
* <li>it stores the conditions under which approvals should be copied to new patchs as a copy
* condition query (field {@code copyCondition})
* <li>it unsets all deprecated fields to control approval copying (fields {@code copyAnyScore},
* {@code copyMinScore}, {@code copyMaxScore}, {@code copyAllScoresIfNoChange}, {@code
* copyAllScoresIfNoCodeChange}, {@code copyAllScoresOnMergeFirstParentUpdate}, {@code
* copyAllScoresOnTrivialRebase}, {@code copyAllScoresIfListOfFilesDidNotChange}, {@code
* copyValue})
* </ul>
*
* <p>This migration assumes {@code true} as default value for the {@code copyAllScoresIfNoChange}
* flag since this default value was used for all labels that were created before this migration has
* been run (for labels that are created after this migration has been run the default value for
* this flag has been changed to {@code false}).
*/
public class MigrateLabelConfigToCopyCondition {
public static final String COMMIT_MESSAGE = "Migrate label configs to copy conditions";
@VisibleForTesting public static final String KEY_COPY_ANY_SCORE = "copyAnyScore";
@VisibleForTesting public static final String KEY_COPY_MIN_SCORE = "copyMinScore";
@VisibleForTesting public static final String KEY_COPY_MAX_SCORE = "copyMaxScore";
@VisibleForTesting public static final String KEY_COPY_VALUE = "copyValue";
@VisibleForTesting
public static final String KEY_COPY_ALL_SCORES_ON_MERGE_FIRST_PARENT_UPDATE =
"copyAllScoresOnMergeFirstParentUpdate";
@VisibleForTesting
public static final String KEY_COPY_ALL_SCORES_ON_TRIVIAL_REBASE = "copyAllScoresOnTrivialRebase";
@VisibleForTesting
public static final String KEY_COPY_ALL_SCORES_IF_NO_CODE_CHANGE = "copyAllScoresIfNoCodeChange";
@VisibleForTesting
public static final String KEY_COPY_ALL_SCORES_IF_NO_CHANGE = "copyAllScoresIfNoChange";
@VisibleForTesting
public static final String KEY_COPY_ALL_SCORES_IF_LIST_OF_FILES_DID_NOT_CHANGE =
"copyAllScoresIfListOfFilesDidNotChange";
private final GitRepositoryManager repoManager;
private final PersonIdent serverUser;
@Inject
public MigrateLabelConfigToCopyCondition(
GitRepositoryManager repoManager, @GerritPersonIdent PersonIdent serverUser) {
this.repoManager = repoManager;
this.serverUser = serverUser;
}
/**
* Executes the migration for the given project.
*
* @param projectName the name of the project for which the migration should be executed
* @throws IOException thrown if an IO error occurs
* @throws ConfigInvalidException thrown if the existing project.config is invalid and cannot be
* parsed
*/
public void execute(Project.NameKey projectName) throws IOException, ConfigInvalidException {
VersionedConfigFile projectConfig = new VersionedConfigFile(ProjectConfig.PROJECT_CONFIG);
try (Repository repo = repoManager.openRepository(projectName);
MetaDataUpdate md = new MetaDataUpdate(GitReferenceUpdated.DISABLED, projectName, repo)) {
boolean isAlreadyMigrated = hasMigrationAlreadyRun(repo);
projectConfig.load(projectName, repo);
Config cfg = projectConfig.getConfig();
String orgConfigAsText = cfg.toText();
for (String labelName : cfg.getSubsections(ProjectConfig.LABEL)) {
String newCopyCondition = computeCopyCondition(isAlreadyMigrated, cfg, labelName);
if (!Strings.isNullOrEmpty(newCopyCondition)) {
cfg.setString(
ProjectConfig.LABEL, labelName, ProjectConfig.KEY_COPY_CONDITION, newCopyCondition);
}
unsetDeprecatedFields(cfg, labelName);
}
if (cfg.toText().equals(orgConfigAsText)) {
// Config was not changed (ie. none of the label definitions had any deprecated field set).
return;
}
md.getCommitBuilder().setAuthor(serverUser);
md.getCommitBuilder().setCommitter(serverUser);
md.setMessage(COMMIT_MESSAGE + "\n");
projectConfig.commit(md);
}
}
private static String computeCopyCondition(
boolean isAlreadyMigrated, Config cfg, String labelName) {
List<String> copyConditions = new ArrayList<>();
ifTrue(cfg, labelName, KEY_COPY_ANY_SCORE, () -> copyConditions.add("is:ANY"));
ifTrue(cfg, labelName, KEY_COPY_MIN_SCORE, () -> copyConditions.add("is:MIN"));
ifTrue(cfg, labelName, KEY_COPY_MAX_SCORE, () -> copyConditions.add("is:MAX"));
forEachSkipNullValues(
cfg,
labelName,
KEY_COPY_VALUE,
value -> copyConditions.add("is:" + quoteIfNegative(parseCopyValue(value))));
ifTrue(
cfg,
labelName,
KEY_COPY_ALL_SCORES_IF_NO_CHANGE,
() -> copyConditions.add("changekind:" + ChangeKind.NO_CHANGE.name()));
// If the migration has already been run on this project we must no longer assume true as
// default value when copyAllScoresIfNoChange is unset. This is to ensure that the migration is
// idempotent when copyAllScoresIfNoChange is set to false:
//
// 1. migration run:
// Removes the copyAllScoresIfNoChange flag, but doesn't add anything to the copy condition.
//
// 2. migration run:
// Since the copyAllScoresIfNoChange flag is no longer set, we would wrongly assume true now and
// wrongly include "changekind:NO_CHANGE" into the copy condition. To prevent this we assume
// false as default for copyAllScoresIfNoChange once the migration has been run, so that the 2.
// migration run is a no-op.
if (!isAlreadyMigrated) {
// The default value for copyAllScoresIfNoChange is true, hence if this parameter is not set
// we need to include "changekind:NO_CHANGE" into the copy condition.
ifUnset(
cfg,
labelName,
KEY_COPY_ALL_SCORES_IF_NO_CHANGE,
() -> copyConditions.add("changekind:" + ChangeKind.NO_CHANGE.name()));
}
ifTrue(
cfg,
labelName,
KEY_COPY_ALL_SCORES_IF_NO_CODE_CHANGE,
() -> copyConditions.add("changekind:" + ChangeKind.NO_CODE_CHANGE.name()));
ifTrue(
cfg,
labelName,
KEY_COPY_ALL_SCORES_ON_MERGE_FIRST_PARENT_UPDATE,
() -> copyConditions.add("changekind:" + ChangeKind.MERGE_FIRST_PARENT_UPDATE.name()));
ifTrue(
cfg,
labelName,
KEY_COPY_ALL_SCORES_ON_TRIVIAL_REBASE,
() -> copyConditions.add("changekind:" + ChangeKind.TRIVIAL_REBASE.name()));
ifTrue(
cfg,
labelName,
KEY_COPY_ALL_SCORES_IF_LIST_OF_FILES_DID_NOT_CHANGE,
() -> copyConditions.add("has:unchanged-files"));
if (copyConditions.isEmpty()) {
// No copy conditions need to be added. Simply return the current copy condition as it is.
// Returning here prevents that OR conditions are reordered and that parentheses are added
// unnecessarily.
return cfg.getString(ProjectConfig.LABEL, labelName, ProjectConfig.KEY_COPY_CONDITION);
}
ifSet(
cfg,
labelName,
ProjectConfig.KEY_COPY_CONDITION,
copyCondition -> copyConditions.addAll(splitOrConditions(copyCondition)));
return copyConditions.stream()
.map(MigrateLabelConfigToCopyCondition::encloseInParenthesesIfNeeded)
.sorted()
// Remove duplicated OR conditions
.distinct()
.collect(joining(" OR "));
}
private static void ifSet(Config cfg, String labelName, String key, Consumer<String> consumer) {
Optional.ofNullable(cfg.getString(ProjectConfig.LABEL, labelName, key)).ifPresent(consumer);
}
private static void ifUnset(Config cfg, String labelName, String key, Runnable runnable) {
Optional<String> value =
Optional.ofNullable(cfg.getString(ProjectConfig.LABEL, labelName, key));
if (!value.isPresent()) {
runnable.run();
}
}
private static void ifTrue(Config cfg, String labelName, String key, Runnable runnable) {
if (cfg.getBoolean(ProjectConfig.LABEL, labelName, key, /* defaultValue= */ false)) {
runnable.run();
}
}
private static void forEachSkipNullValues(
Config cfg, String labelName, String key, Consumer<String> consumer) {
Arrays.stream(cfg.getStringList(ProjectConfig.LABEL, labelName, key))
.filter(Objects::nonNull)
.forEach(consumer);
}
private static void unsetDeprecatedFields(Config cfg, String labelName) {
cfg.unset(ProjectConfig.LABEL, labelName, KEY_COPY_ANY_SCORE);
cfg.unset(ProjectConfig.LABEL, labelName, KEY_COPY_MIN_SCORE);
cfg.unset(ProjectConfig.LABEL, labelName, KEY_COPY_MAX_SCORE);
cfg.unset(ProjectConfig.LABEL, labelName, KEY_COPY_VALUE);
cfg.unset(ProjectConfig.LABEL, labelName, KEY_COPY_ALL_SCORES_IF_NO_CHANGE);
cfg.unset(ProjectConfig.LABEL, labelName, KEY_COPY_ALL_SCORES_IF_NO_CODE_CHANGE);
cfg.unset(ProjectConfig.LABEL, labelName, KEY_COPY_ALL_SCORES_ON_MERGE_FIRST_PARENT_UPDATE);
cfg.unset(ProjectConfig.LABEL, labelName, KEY_COPY_ALL_SCORES_ON_TRIVIAL_REBASE);
cfg.unset(ProjectConfig.LABEL, labelName, KEY_COPY_ALL_SCORES_IF_LIST_OF_FILES_DID_NOT_CHANGE);
}
private static ImmutableList<String> splitOrConditions(String copyCondition) {
if (copyCondition.contains("(") || copyCondition.contains(")")) {
// cannot parse complex predicate tree
return ImmutableList.of(copyCondition);
}
// split query on OR, this way we can detect and remove duplicate OR conditions later
return ImmutableList.copyOf(Splitter.on(" OR ").splitToList(copyCondition));
}
/**
* Add parentheses around the given copyCondition if it consists out of 2 or more predicates and
* if it isn't enclosed in parentheses yet.
*/
private static String encloseInParenthesesIfNeeded(String copyCondition) {
if (copyCondition.contains(" ")
&& !(copyCondition.startsWith("(") && copyCondition.endsWith(")"))) {
return "(" + copyCondition + ")";
}
return copyCondition;
}
private static short parseCopyValue(String value) {
return Shorts.checkedCast(PermissionRule.parseInt(value));
}
private static String quoteIfNegative(short value) {
if (value < 0) {
return "\"" + value + "\"";
}
return Integer.toString(value);
}
public static boolean hasMigrationAlreadyRun(Repository repo) throws IOException {
try (RevWalk revWalk = new RevWalk(repo)) {
Ref refsMetaConfig = repo.exactRef(RefNames.REFS_CONFIG);
if (refsMetaConfig == null) {
return false;
}
revWalk.markStart(revWalk.parseCommit(refsMetaConfig.getObjectId()));
RevCommit commit;
while ((commit = revWalk.next()) != null) {
if (COMMIT_MESSAGE.equals(commit.getShortMessage())) {
return true;
}
}
return false;
}
}
}