| // 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; |
| } |
| } |
| } |