| // 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.auto.value.AutoValue; |
| import com.google.common.collect.ImmutableList; |
| import com.google.gerrit.entities.LabelFunction; |
| import com.google.gerrit.entities.LabelType; |
| 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.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 java.io.IOException; |
| import java.util.Arrays; |
| import java.util.Collection; |
| import java.util.HashMap; |
| import java.util.LinkedHashMap; |
| import java.util.List; |
| import java.util.Locale; |
| import java.util.Map; |
| import java.util.Optional; |
| import java.util.stream.Collectors; |
| import javax.inject.Inject; |
| import org.eclipse.jgit.errors.ConfigInvalidException; |
| import org.eclipse.jgit.lib.Config; |
| import org.eclipse.jgit.lib.ObjectReader; |
| 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; |
| 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 GitRepositoryManager repoManager; |
| private final PersonIdent serverUser; |
| |
| 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( |
| GitRepositoryManager repoManager, @GerritPersonIdent PersonIdent serverUser) { |
| this.repoManager = repoManager; |
| this.serverUser = serverUser; |
| } |
| |
| /** |
| * 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 { |
| if (hasPrologRules(project)) { |
| ui.message(String.format("Skipping project %s because it has prolog rules", project)); |
| return Status.HAS_PROLOG; |
| } |
| VersionedConfigFile projectConfig = new VersionedConfigFile(ProjectConfig.PROJECT_CONFIG); |
| boolean migrationPerformed = false; |
| try (Repository repo = repoManager.openRepository(project); |
| MetaDataUpdate md = new MetaDataUpdate(GitReferenceUpdated.DISABLED, project, repo)) { |
| if (hasMigrationAlreadyRun(repo)) { |
| 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; |
| } |
| projectConfig.load(project, repo); |
| Config cfg = projectConfig.getConfig(); |
| Map<String, LabelAttributes> labelTypes = getLabelTypes(cfg); |
| Map<String, SubmitRequirement> existingSubmitRequirements = loadSubmitRequirements(cfg); |
| boolean updated = false; |
| for (Map.Entry<String, LabelAttributes> lt : labelTypes.entrySet()) { |
| String labelName = lt.getKey(); |
| LabelAttributes attributes = lt.getValue(); |
| if (attributes.function().equals("PatchSetLock")) { |
| // 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 (!attributes.function().equals("NoBlock")) { |
| updated = true; |
| writeLabelFunction(cfg, labelName, "NoBlock"); |
| } |
| Optional<SubmitRequirement> sr = createSrFromLabelDef(labelName, attributes); |
| 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.toLowerCase(Locale.ROOT))) { |
| if (!sr.get() |
| .equals(existingSubmitRequirements.get(labelName.toLowerCase(Locale.ROOT)))) { |
| 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)); |
| writeSubmitRequirement(cfg, sr.get()); |
| } |
| if (updated) { |
| commit(projectConfig, md); |
| migrationPerformed = true; |
| } |
| } |
| return migrationPerformed ? Status.MIGRATED : Status.NO_CHANGE; |
| } |
| |
| /** |
| * Returns a Map containing label names as string in keys along with some of its attributes (that |
| * we need in the migration) like canOverride, ignoreSelfApproval and function in the value. |
| */ |
| private Map<String, LabelAttributes> getLabelTypes(Config cfg) { |
| Map<String, LabelAttributes> labelTypes = new HashMap<>(); |
| for (String labelName : cfg.getSubsections(ProjectConfig.LABEL)) { |
| String function = cfg.getString(ProjectConfig.LABEL, labelName, ProjectConfig.KEY_FUNCTION); |
| boolean canOverride = |
| cfg.getBoolean( |
| ProjectConfig.LABEL, |
| labelName, |
| ProjectConfig.KEY_CAN_OVERRIDE, |
| /* defaultValue= */ true); |
| boolean ignoreSelfApproval = |
| cfg.getBoolean( |
| ProjectConfig.LABEL, |
| labelName, |
| ProjectConfig.KEY_IGNORE_SELF_APPROVAL, |
| /* defaultValue= */ false); |
| ImmutableList<String> values = |
| ImmutableList.<String>builder() |
| .addAll( |
| Arrays.asList( |
| cfg.getStringList(ProjectConfig.LABEL, labelName, ProjectConfig.KEY_VALUE))) |
| .build(); |
| ImmutableList<String> refPatterns = |
| ImmutableList.<String>builder() |
| .addAll( |
| Arrays.asList( |
| cfg.getStringList(ProjectConfig.LABEL, labelName, ProjectConfig.KEY_BRANCH))) |
| .build(); |
| LabelAttributes attributes = |
| LabelAttributes.create( |
| function == null ? "MaxWithBlock" : function, |
| canOverride, |
| ignoreSelfApproval, |
| values, |
| refPatterns); |
| labelTypes.put(labelName, attributes); |
| } |
| return labelTypes; |
| } |
| |
| private void writeSubmitRequirement(Config cfg, SubmitRequirement sr) { |
| if (sr.description().isPresent()) { |
| cfg.setString( |
| ProjectConfig.SUBMIT_REQUIREMENT, |
| sr.name(), |
| ProjectConfig.KEY_SR_DESCRIPTION, |
| sr.description().get()); |
| } |
| if (sr.applicabilityExpression().isPresent()) { |
| cfg.setString( |
| ProjectConfig.SUBMIT_REQUIREMENT, |
| sr.name(), |
| ProjectConfig.KEY_SR_APPLICABILITY_EXPRESSION, |
| sr.applicabilityExpression().get().expressionString()); |
| } |
| cfg.setString( |
| ProjectConfig.SUBMIT_REQUIREMENT, |
| sr.name(), |
| ProjectConfig.KEY_SR_SUBMITTABILITY_EXPRESSION, |
| sr.submittabilityExpression().expressionString()); |
| if (sr.overrideExpression().isPresent()) { |
| cfg.setString( |
| ProjectConfig.SUBMIT_REQUIREMENT, |
| sr.name(), |
| ProjectConfig.KEY_SR_OVERRIDE_EXPRESSION, |
| sr.overrideExpression().get().expressionString()); |
| } |
| cfg.setBoolean( |
| ProjectConfig.SUBMIT_REQUIREMENT, |
| sr.name(), |
| ProjectConfig.KEY_SR_OVERRIDE_IN_CHILD_PROJECTS, |
| sr.allowOverrideInChildProjects()); |
| } |
| |
| private void writeLabelFunction(Config cfg, String labelName, String function) { |
| cfg.setString(ProjectConfig.LABEL, labelName, ProjectConfig.KEY_FUNCTION, function); |
| } |
| |
| private void commit(VersionedConfigFile projectConfig, MetaDataUpdate md) throws IOException { |
| md.getCommitBuilder().setAuthor(serverUser); |
| md.getCommitBuilder().setCommitter(serverUser); |
| md.setMessage(COMMIT_MSG); |
| projectConfig.commit(md); |
| } |
| |
| private static Optional<SubmitRequirement> createSrFromLabelDef( |
| String labelName, LabelAttributes attributes) { |
| if (isLabelSkipped(attributes.values())) { |
| return Optional.of(createNonApplicableSr(labelName, attributes.canOverride())); |
| } else if (isBlockingOrRequiredLabel(attributes.function())) { |
| return Optional.of(createBlockingOrRequiredSr(labelName, attributes)); |
| } |
| return Optional.empty(); |
| } |
| |
| private static SubmitRequirement createNonApplicableSr(String labelName, boolean canOverride) { |
| return SubmitRequirement.builder() |
| .setName(labelName) |
| .setApplicabilityExpression(SubmitRequirementExpression.of("is:false")) |
| .setSubmittabilityExpression(SubmitRequirementExpression.create("is:true")) |
| .setAllowOverrideInChildProjects(canOverride) |
| .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( |
| String labelName, LabelAttributes attributes) { |
| SubmitRequirement.Builder builder = |
| SubmitRequirement.builder() |
| .setName(labelName) |
| .setAllowOverrideInChildProjects(attributes.canOverride()); |
| String maxPart = |
| String.format("label:%s=MAX", labelName) |
| + (attributes.ignoreSelfApproval() ? ",user=non_uploader" : ""); |
| switch (attributes.function()) { |
| case "MaxWithBlock": |
| builder.setSubmittabilityExpression( |
| SubmitRequirementExpression.create( |
| String.format("%s AND -label:%s=MIN", maxPart, labelName))); |
| break; |
| case "AnyWithBlock": |
| builder.setSubmittabilityExpression( |
| SubmitRequirementExpression.create(String.format("-label:%s=MIN", labelName))); |
| break; |
| case "MaxNoBlock": |
| builder.setSubmittabilityExpression(SubmitRequirementExpression.create(maxPart)); |
| break; |
| default: |
| break; |
| } |
| if (!attributes.refPatterns().isEmpty()) { |
| builder.setApplicabilityExpression( |
| SubmitRequirementExpression.of( |
| String.join( |
| " OR ", |
| attributes.refPatterns().stream() |
| .map(b -> "branch:\\\"" + b + "\\\"") |
| .collect(Collectors.toList())))); |
| } |
| return builder.build(); |
| } |
| |
| private static boolean isBlockingOrRequiredLabel(String function) { |
| return function.equals("AnyWithBlock") |
| || function.equals("MaxWithBlock") |
| || function.equals("MaxNoBlock"); |
| } |
| |
| /** |
| * Returns true if the label definition was skipped in the project, i.e. it had only one defined |
| * value: zero. |
| */ |
| private static boolean isLabelSkipped(List<String> values) { |
| return values.isEmpty() || (values.size() == 1 && values.get(0).startsWith("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; |
| } |
| } |
| |
| /** |
| * Returns a map containing submit requirement names in lower name as keys, with {@link |
| * com.google.gerrit.entities.SubmitRequirement} as value. |
| */ |
| private Map<String, SubmitRequirement> loadSubmitRequirements(Config rc) { |
| Map<String, SubmitRequirement> allRequirements = new LinkedHashMap<>(); |
| for (String name : rc.getSubsections(ProjectConfig.SUBMIT_REQUIREMENT)) { |
| String description = |
| rc.getString(ProjectConfig.SUBMIT_REQUIREMENT, name, ProjectConfig.KEY_SR_DESCRIPTION); |
| String applicabilityExpr = |
| rc.getString( |
| ProjectConfig.SUBMIT_REQUIREMENT, |
| name, |
| ProjectConfig.KEY_SR_APPLICABILITY_EXPRESSION); |
| String submittabilityExpr = |
| rc.getString( |
| ProjectConfig.SUBMIT_REQUIREMENT, |
| name, |
| ProjectConfig.KEY_SR_SUBMITTABILITY_EXPRESSION); |
| String overrideExpr = |
| rc.getString( |
| ProjectConfig.SUBMIT_REQUIREMENT, name, ProjectConfig.KEY_SR_OVERRIDE_EXPRESSION); |
| boolean canInherit = |
| rc.getBoolean( |
| ProjectConfig.SUBMIT_REQUIREMENT, |
| name, |
| ProjectConfig.KEY_SR_OVERRIDE_IN_CHILD_PROJECTS, |
| false); |
| SubmitRequirement submitRequirement = |
| SubmitRequirement.builder() |
| .setName(name) |
| .setDescription(Optional.ofNullable(description)) |
| .setApplicabilityExpression(SubmitRequirementExpression.of(applicabilityExpr)) |
| .setSubmittabilityExpression(SubmitRequirementExpression.create(submittabilityExpr)) |
| .setOverrideExpression(SubmitRequirementExpression.of(overrideExpr)) |
| .setAllowOverrideInChildProjects(canInherit) |
| .build(); |
| allRequirements.put(name.toLowerCase(Locale.ROOT), submitRequirement); |
| } |
| return allRequirements; |
| } |
| |
| private 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_MSG.equals(commit.getShortMessage())) { |
| return true; |
| } |
| } |
| return false; |
| } |
| } |
| |
| @AutoValue |
| abstract static class LabelAttributes { |
| abstract String function(); |
| |
| abstract boolean canOverride(); |
| |
| abstract boolean ignoreSelfApproval(); |
| |
| abstract ImmutableList<String> values(); |
| |
| abstract ImmutableList<String> refPatterns(); |
| |
| static LabelAttributes create( |
| String function, |
| boolean canOverride, |
| boolean ignoreSelfApproval, |
| ImmutableList<String> values, |
| ImmutableList<String> refPatterns) { |
| return new AutoValue_MigrateLabelFunctionsToSubmitRequirement_LabelAttributes( |
| function, canOverride, ignoreSelfApproval, values, refPatterns); |
| } |
| } |
| } |