blob: bdc521e1ced4e7a94cfdf404d26bfb672103f3d1 [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 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);
}
}
}