| // 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 com.google.gerrit.server.project.ProjectConfig.RULES_PL_FILE; |
| |
| import com.google.common.collect.ImmutableList; |
| import com.google.gerrit.common.Nullable; |
| import com.google.gerrit.entities.LabelFunction; |
| import com.google.gerrit.entities.LabelType; |
| import com.google.gerrit.entities.LabelValue; |
| import com.google.gerrit.entities.Project; |
| import com.google.gerrit.entities.RefNames; |
| import com.google.gerrit.entities.SubmitRequirement; |
| import com.google.gerrit.entities.SubmitRequirementExpression; |
| import com.google.gerrit.extensions.restapi.MethodNotAllowedException; |
| import com.google.gerrit.server.git.GitRepositoryManager; |
| import com.google.gerrit.server.permissions.PermissionBackendException; |
| import com.google.gerrit.server.project.ProjectConfig; |
| import com.google.gerrit.server.project.RepoMetaDataUpdater; |
| import com.google.gerrit.server.project.RepoMetaDataUpdater.ConfigUpdater; |
| import com.google.inject.Inject; |
| import java.io.IOException; |
| import java.util.Collection; |
| import java.util.Locale; |
| import java.util.Map; |
| import java.util.Optional; |
| import java.util.stream.Collectors; |
| import org.eclipse.jgit.errors.ConfigInvalidException; |
| import org.eclipse.jgit.lib.ObjectReader; |
| import org.eclipse.jgit.lib.Ref; |
| 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 class with logic for migrating existing label functions to submit requirements and resetting |
| * the label functions to {@link LabelFunction#NO_BLOCK}. |
| * |
| * <p>Important note: Callers should do this migration only if this gerrit installation has no |
| * Prolog submit rules (i.e. no rules.pl file in refs/meta/config). Otherwise, the newly created |
| * submit requirements might not behave as intended. |
| * |
| * <p>The conversion is done as follows: |
| * |
| * <ul> |
| * <li>MaxWithBlock is translated to submittableIf = label:$lbl=MAX AND -label:$lbl=MIN |
| * <li>MaxNoBlock is translated to submittableIf = label:$lbl=MAX |
| * <li>AnyWithBlock is translated to submittableIf = -label:$lbl=MIN |
| * <li>NoBlock/NoOp are translated to applicableIf = is:false (not applicable) |
| * <li>PatchSetLock labels are left as is |
| * </ul> |
| * |
| * If the label has {@link LabelType#isIgnoreSelfApproval()}, the max vote is appended with the |
| * 'user=non_uploader' argument. |
| * |
| * <p>For labels that were skipped, i.e. had only one "zero" predefined value, the migrator creates |
| * a non-applicable submit-requirement for them. This is done so that if a parent project had a |
| * submit-requirement with the same name, then it's not inherited by this project. |
| * |
| * <p>If there is an existing label and there exists a "submit requirement" with the same name, the |
| * migrator checks if the submit-requirement to be created matches the one in project.config. If |
| * they don't match, a warning message is printed, otherwise nothing happens. In either cases, the |
| * existing submit-requirement is not altered. |
| */ |
| public class MigrateLabelFunctionsToSubmitRequirement { |
| public static final String COMMIT_MSG = "Migrate label functions to submit requirements"; |
| |
| private final RepoMetaDataUpdater repoMetaDataUpdater; |
| private final GitRepositoryManager repoManager; |
| |
| public enum Status { |
| /** |
| * The migrator updated the project config and created new submit requirements and/or did reset |
| * label functions. |
| */ |
| MIGRATED, |
| |
| /** The project had prolog rules, and the migration was skipped. */ |
| HAS_PROLOG, |
| |
| /** |
| * The project was migrated with a previous run of this class. The migration for this run was |
| * skipped. |
| */ |
| PREVIOUSLY_MIGRATED, |
| |
| /** |
| * Migration was run for the project but did not update the project.config because it was |
| * up-to-date. |
| */ |
| NO_CHANGE |
| } |
| |
| @Inject |
| public MigrateLabelFunctionsToSubmitRequirement( |
| RepoMetaDataUpdater repoMetaDataUpdater, GitRepositoryManager repoManager) { |
| this.repoMetaDataUpdater = repoMetaDataUpdater; |
| this.repoManager = repoManager; |
| } |
| |
| /** |
| * For each label function, create a corresponding submit-requirement and set the label function |
| * to NO_BLOCK. Blocking label functions are substituted with blocking submit-requirements. |
| * Non-blocking label functions are substituted with non-applicable submit requirements, allowing |
| * the label vote to be surfaced as a trigger vote (optional label). |
| * |
| * @return {@link Status} reflecting the status of the migration. |
| */ |
| public Status executeMigration(Project.NameKey project, UpdateUI ui) |
| throws IOException, |
| ConfigInvalidException, |
| MethodNotAllowedException, |
| PermissionBackendException { |
| try (ConfigUpdater updater = |
| repoMetaDataUpdater.configUpdaterWithoutPermissionsCheck(project, null, COMMIT_MSG)) { |
| Status result = updateConfig(project, updater.getConfig(), ui); |
| if (result == Status.MIGRATED) { |
| updater.commitConfigUpdate(); |
| } |
| return result; |
| } |
| } |
| |
| public Status updateConfig(Project.NameKey project, ProjectConfig projectConfig, UpdateUI ui) |
| throws IOException { |
| boolean updated = false; |
| if (hasPrologRules(project)) { |
| ui.message(String.format("Skipping project %s because it has prolog rules", project)); |
| return Status.HAS_PROLOG; |
| } |
| |
| if (hasMigrationAlreadyRun(project)) { |
| ui.message( |
| String.format( |
| "Skipping migrating label functions to submit requirements for project '%s'" |
| + " because it has been previously migrated", |
| project)); |
| return Status.PREVIOUSLY_MIGRATED; |
| } |
| |
| Map<String, LabelType> labelSections = projectConfig.getLabelSections(); |
| SubmitRequirementMap existingSubmitRequirements = |
| new SubmitRequirementMap(projectConfig.getSubmitRequirementSections()); |
| |
| for (Map.Entry<String, LabelType> section : labelSections.entrySet()) { |
| String labelName = section.getKey(); |
| LabelType labelType = section.getValue(); |
| |
| if (labelType.getFunction() == LabelFunction.PATCH_SET_LOCK) { |
| // PATCH_SET_LOCK functions should be left as is |
| continue; |
| } |
| |
| // If the function is other than "NoBlock" we want to reset the label function regardless |
| // of whether there exists a "submit requirement". |
| if (labelType.getFunction() != LabelFunction.NO_BLOCK) { |
| section.setValue(labelType.toBuilder().setNoBlockFunction().build()); |
| updated = true; |
| } |
| |
| Optional<SubmitRequirement> sr = createSrFromLabelDef(labelType); |
| if (!sr.isPresent()) { |
| continue; |
| } |
| // Make the operation idempotent by skipping creating the submit-requirement if one was |
| // already created or previously existed. |
| if (existingSubmitRequirements.containsKey(labelName)) { |
| SubmitRequirement existing = existingSubmitRequirements.get(labelName); |
| if (!sr.get().equals(existing)) { |
| ui.message( |
| String.format( |
| "Warning: Skipping creating a submit requirement for label '%s'. An existing " |
| + "submit requirement is already present but its definition is not " |
| + "identical to the existing label definition.", |
| labelName)); |
| } |
| continue; |
| } |
| updated = true; |
| ui.message( |
| String.format( |
| "Project %s: Creating a submit requirement for label %s", project, labelName)); |
| existingSubmitRequirements.put(sr.get()); |
| } |
| return updated ? Status.MIGRATED : Status.NO_CHANGE; |
| } |
| |
| private static Optional<SubmitRequirement> createSrFromLabelDef(LabelType lt) { |
| if (isLabelSkipped(lt)) { |
| return Optional.of(createNonApplicableSr(lt)); |
| } else if (isBlockingOrRequiredLabel(lt)) { |
| return Optional.of(createBlockingOrRequiredSr(lt)); |
| } |
| return Optional.empty(); |
| } |
| |
| private static SubmitRequirement createNonApplicableSr(LabelType lt) { |
| return SubmitRequirement.builder() |
| .setName(lt.getName()) |
| .setApplicabilityExpression(SubmitRequirementExpression.of("is:false")) |
| .setSubmittabilityExpression(SubmitRequirementExpression.create("is:true")) |
| .setAllowOverrideInChildProjects(lt.isCanOverride()) |
| .build(); |
| } |
| |
| /** |
| * Create a "submit requirement" that is only satisfied if the label is voted with the max votes |
| * and/or not voted by the min vote, according to the label attributes. |
| */ |
| private static SubmitRequirement createBlockingOrRequiredSr(LabelType lt) { |
| SubmitRequirement.Builder builder = |
| SubmitRequirement.builder() |
| .setName(lt.getName()) |
| .setAllowOverrideInChildProjects(lt.isCanOverride()); |
| String maxPart = |
| String.format("label:%s=MAX", lt.getName()) |
| + (lt.isIgnoreSelfApproval() ? ",user=non_uploader" : ""); |
| switch (lt.getFunction()) { |
| case MAX_WITH_BLOCK -> |
| builder.setSubmittabilityExpression( |
| SubmitRequirementExpression.create( |
| String.format("%s AND -label:%s=MIN", maxPart, lt.getName()))); |
| case ANY_WITH_BLOCK -> |
| builder.setSubmittabilityExpression( |
| SubmitRequirementExpression.create(String.format("-label:%s=MIN", lt.getName()))); |
| case MAX_NO_BLOCK -> |
| builder.setSubmittabilityExpression(SubmitRequirementExpression.create(maxPart)); |
| default -> {} |
| } |
| ImmutableList<String> refPatterns = lt.getRefPatterns(); |
| if (refPatterns != null && !refPatterns.isEmpty()) { |
| builder.setApplicabilityExpression( |
| SubmitRequirementExpression.of( |
| String.join( |
| " OR ", |
| lt.getRefPatterns().stream() |
| .map(b -> "branch:\\\"" + b + "\\\"") |
| .collect(Collectors.toList())))); |
| } |
| return builder.build(); |
| } |
| |
| private static boolean isBlockingOrRequiredLabel(LabelType lt) { |
| return switch (lt.getFunction()) { |
| case ANY_WITH_BLOCK, MAX_WITH_BLOCK, MAX_NO_BLOCK -> true; |
| case NO_BLOCK, NO_OP, PATCH_SET_LOCK -> false; |
| }; |
| } |
| |
| private static boolean isLabelSkipped(LabelType lt) { |
| ImmutableList<LabelValue> values = lt.getValues(); |
| return values.isEmpty() || (values.size() == 1 && values.get(0).getValue() == 0); |
| } |
| |
| public boolean anyProjectHasProlog(Collection<Project.NameKey> allProjects) throws IOException { |
| for (Project.NameKey p : allProjects) { |
| if (hasPrologRules(p)) { |
| return true; |
| } |
| } |
| return false; |
| } |
| |
| private boolean hasPrologRules(Project.NameKey project) throws IOException { |
| try (Repository repo = repoManager.openRepository(project); |
| RevWalk rw = new RevWalk(repo); |
| ObjectReader reader = rw.getObjectReader()) { |
| Ref refsConfig = repo.exactRef(RefNames.REFS_CONFIG); |
| if (refsConfig == null) { |
| // Project does not have a refs/meta/config and no rules.pl consequently. |
| return false; |
| } |
| RevCommit commit = repo.parseCommit(refsConfig.getObjectId()); |
| try (TreeWalk tw = TreeWalk.forPath(reader, RULES_PL_FILE, commit.getTree())) { |
| if (tw != null) { |
| return true; |
| } |
| } |
| |
| return false; |
| } |
| } |
| |
| private boolean hasMigrationAlreadyRun(Project.NameKey project) throws IOException { |
| try (Repository repo = repoManager.openRepository(project)) { |
| 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_MSG.equals(commit.getShortMessage())) { |
| return true; |
| } |
| } |
| return false; |
| } |
| } |
| } |
| |
| /** |
| * Helper "Map" to of submit requirements with case-preserving keys and case-insensitive lookup |
| */ |
| private static class SubmitRequirementMap { |
| private final Map<String, SubmitRequirement> submitRequirements; |
| private final Map<String, String> lowerCaseToOriginalNames; |
| |
| SubmitRequirementMap(Map<String, SubmitRequirement> submitRequirements) { |
| this.submitRequirements = submitRequirements; |
| this.lowerCaseToOriginalNames = |
| submitRequirements.keySet().stream() |
| .collect(Collectors.toMap(k -> k.toLowerCase(Locale.ROOT), k -> k)); |
| } |
| |
| boolean containsKey(String name) { |
| return lowerCaseToOriginalNames.containsKey(name.toLowerCase(Locale.ROOT)); |
| } |
| |
| @Nullable |
| SubmitRequirement get(String name) { |
| String orig = lowerCaseToOriginalNames.get(name.toLowerCase(Locale.ROOT)); |
| return orig != null ? submitRequirements.get(orig) : null; |
| } |
| |
| void put(SubmitRequirement sr) { |
| String name = sr.name(); |
| submitRequirements.put(name, sr); |
| lowerCaseToOriginalNames.put(name.toLowerCase(Locale.ROOT), name); |
| } |
| } |
| } |