blob: 0d8dac47be20c51629a8180c6f9fe77ea3da9ecd [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.googlesource.gerrit.owners;
import static com.google.gerrit.server.project.ProjectCache.illegalState;
import static java.util.Objects.requireNonNull;
import static java.util.stream.Collectors.toSet;
import com.google.common.base.Joiner;
import com.google.common.collect.Streams;
import com.google.common.flogger.FluentLogger;
import com.google.gerrit.entities.Account;
import com.google.gerrit.entities.Change;
import com.google.gerrit.entities.LabelFunction;
import com.google.gerrit.entities.LabelType;
import com.google.gerrit.entities.LabelTypes;
import com.google.gerrit.entities.LegacySubmitRequirement;
import com.google.gerrit.entities.PatchSetApproval;
import com.google.gerrit.entities.Project;
import com.google.gerrit.entities.SubmitRecord;
import com.google.gerrit.extensions.annotations.Exports;
import com.google.gerrit.metrics.Timer0;
import com.google.gerrit.server.approval.ApprovalsUtil;
import com.google.gerrit.server.git.GitRepositoryManager;
import com.google.gerrit.server.notedb.ChangeNotes;
import com.google.gerrit.server.patch.DiffNotAvailableException;
import com.google.gerrit.server.patch.DiffOperations;
import com.google.gerrit.server.patch.DiffOptions;
import com.google.gerrit.server.patch.filediff.FileDiffOutput;
import com.google.gerrit.server.project.ProjectCache;
import com.google.gerrit.server.project.ProjectState;
import com.google.gerrit.server.query.change.ChangeData;
import com.google.gerrit.server.rules.SubmitRule;
import com.google.inject.AbstractModule;
import com.google.inject.Inject;
import com.google.inject.Singleton;
import com.googlesource.gerrit.owners.common.Accounts;
import com.googlesource.gerrit.owners.common.InvalidOwnersFileException;
import com.googlesource.gerrit.owners.common.LabelDefinition;
import com.googlesource.gerrit.owners.common.PathOwners;
import com.googlesource.gerrit.owners.common.PathOwnersEntriesCache;
import com.googlesource.gerrit.owners.common.PluginSettings;
import java.io.IOException;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;
import org.eclipse.jgit.lib.ObjectId;
import org.eclipse.jgit.lib.Repository;
@Singleton
public class OwnersSubmitRequirement implements SubmitRule {
public static class OwnersSubmitRequirementModule extends AbstractModule {
@Override
public void configure() {
bind(SubmitRule.class)
.annotatedWith(Exports.named("OwnersSubmitRequirement"))
.to(OwnersSubmitRequirement.class);
}
}
private static final FluentLogger logger = FluentLogger.forEnclosingClass();
private static final LegacySubmitRequirement SUBMIT_REQUIREMENT =
LegacySubmitRequirement.builder().setFallbackText("Owners").setType("owners").build();
private final OwnersMetrics metrics;
private final PluginSettings pluginSettings;
private final ProjectCache projectCache;
private final Accounts accounts;
private final GitRepositoryManager repoManager;
private final DiffOperations diffOperations;
private final ApprovalsUtil approvalsUtil;
private final PathOwnersEntriesCache cache;
@Inject
OwnersSubmitRequirement(
OwnersMetrics metrics,
PluginSettings pluginSettings,
ProjectCache projectCache,
Accounts accounts,
GitRepositoryManager repoManager,
DiffOperations diffOperations,
ApprovalsUtil approvalsUtil,
PathOwnersEntriesCache cache) {
this.metrics = metrics;
this.pluginSettings = pluginSettings;
this.projectCache = projectCache;
this.accounts = accounts;
this.repoManager = repoManager;
this.diffOperations = diffOperations;
this.approvalsUtil = approvalsUtil;
this.cache = cache;
}
@Override
public Optional<SubmitRecord> evaluate(ChangeData cd) {
requireNonNull(cd, "changeData");
Change change = cd.change();
Project.NameKey project = cd.project();
int changeId = cd.getId().get();
if (change.isClosed()) {
logger.atFine().log(
"Project '%s': change #%d is closed therefore OWNERS submit requirements are skipped.",
project, changeId);
return Optional.empty();
}
metrics.countSubmitRuleRuns.increment();
try (Timer0.Context ctx = metrics.runSubmitRule.start()) {
ProjectState projectState = getProjectState(project);
PathOwners pathOwners = getPathOwners(cd, projectState);
Map<String, Set<Account.Id>> fileOwners = pathOwners.getFileOwners();
if (fileOwners.isEmpty()) {
logger.atFinest().log(
"Project '%s': change #%d has no OWNERS submit requirements defined. "
+ "Skipping submit requirements.",
project, changeId);
return Optional.empty();
}
ChangeNotes notes = cd.notes();
requireNonNull(notes, "notes");
LabelTypes labelTypes = projectState.getLabelTypes(notes);
LabelDefinition label = resolveLabel(labelTypes, pathOwners.getLabel());
Optional<LabelAndScore> ownersLabel = ownersLabel(labelTypes, label, project);
Map<Account.Id, List<PatchSetApproval>> approvalsByAccount =
Streams.stream(approvalsUtil.byPatchSet(notes, cd.currentPatchSet().id()))
.collect(Collectors.groupingBy(PatchSetApproval::accountId));
Account.Id uploader = notes.getCurrentPatchSet().uploader();
Set<String> missingApprovals =
fileOwners.entrySet().stream()
.filter(
requiredApproval ->
ownersLabel
.map(
ol ->
isApprovalMissing(
requiredApproval, uploader, approvalsByAccount, ol))
.orElse(true))
.map(Map.Entry::getKey)
.collect(toSet());
return Optional.of(
missingApprovals.isEmpty()
? ok()
: notReady(
label.getName(),
String.format(
"Missing approvals for path(s): [%s]",
Joiner.on(", ").join(missingApprovals))));
} catch (InvalidOwnersFileException e) {
logger.atSevere().withCause(e).log("Reading/parsing OWNERS file error.");
return Optional.of(ruleError(e.getMessage()));
} catch (IOException e) {
String msg =
String.format(
"Project '%s': repository cannot be opened to evaluate OWNERS submit requirements.",
project);
logger.atSevere().withCause(e).log("%s", msg);
throw new IllegalStateException(msg, e);
} catch (DiffNotAvailableException e) {
String msg =
String.format(
"Project '%s' change #%d: unable to get diff to evaluate OWNERS submit requirements.",
project, changeId);
logger.atSevere().withCause(e).log("%s", msg);
throw new IllegalStateException(msg, e);
}
}
private ProjectState getProjectState(Project.NameKey project) {
ProjectState projectState = projectCache.get(project).orElseThrow(illegalState(project));
if (projectState.hasPrologRules()) {
logger.atInfo().atMostEvery(1, TimeUnit.DAYS).log(
"Project '%s' has prolog rules enabled. "
+ "It may interfere with the OWNERS submit requirements evaluation.",
project);
}
return projectState;
}
private PathOwners getPathOwners(ChangeData cd, ProjectState projectState)
throws IOException, DiffNotAvailableException, InvalidOwnersFileException {
metrics.countConfigLoads.increment();
try (Timer0.Context ctx = metrics.loadConfig.start()) {
String branch = cd.change().getDest().branch();
List<Project.NameKey> parents = PathOwners.getParents(projectState);
Project.NameKey nameKey = projectState.getNameKey();
try (Repository repo = repoManager.openRepository(nameKey)) {
PathOwners pathOwners =
new PathOwners(
accounts,
repoManager,
repo,
parents,
pluginSettings.isBranchDisabled(branch) ? Optional.empty() : Optional.of(branch),
getDiff(nameKey, cd.currentPatchSet().commitId()),
pluginSettings.expandGroups(),
nameKey.get(),
cache);
return pathOwners;
}
}
}
/**
* The idea is to select the label type that is configured for owner to cast the vote. If nothing
* is configured in the OWNERS file then `Code-Review` will be selected.
*
* @param labelTypes labels configured for project
* @param label and score definition that is configured in the OWNERS file
*/
static LabelDefinition resolveLabel(LabelTypes labelTypes, Optional<LabelDefinition> label) {
return label.orElse(LabelDefinition.CODE_REVIEW);
}
/**
* Create {@link LabelAndScore} definition with a label LabelType if label can be found or empty
* otherwise. Note that score definition is copied from the OWNERS.
*
* @param labelTypes labels configured for project
* @param label and score definition (optional) that is resolved from the OWNERS file
* @param project that change is evaluated for
*/
static Optional<LabelAndScore> ownersLabel(
LabelTypes labelTypes, LabelDefinition label, Project.NameKey project) {
return labelTypes
.byLabel(label.getName())
.map(type -> new LabelAndScore(type, label.getScore()))
.or(
() -> {
logger.atSevere().log(
"OWNERS label '%s' is not configured for '%s' project. Change is not submittable.",
label, project);
return Optional.empty();
});
}
static boolean isApprovalMissing(
Map.Entry<String, Set<Account.Id>> requiredApproval,
Account.Id uploader,
Map<Account.Id, List<PatchSetApproval>> approvalsByAccount,
LabelAndScore ownersLabel) {
return requiredApproval.getValue().stream()
.noneMatch(
fileOwner -> isApprovedByOwner(fileOwner, uploader, approvalsByAccount, ownersLabel));
}
static boolean isApprovedByOwner(
Account.Id fileOwner,
Account.Id uploader,
Map<Account.Id, List<PatchSetApproval>> approvalsByAccount,
LabelAndScore ownersLabel) {
return Optional.ofNullable(approvalsByAccount.get(fileOwner))
.map(
approvals ->
approvals.stream()
.anyMatch(
approval ->
hasSufficientApproval(approval, ownersLabel, fileOwner, uploader)))
.orElse(false);
}
static boolean hasSufficientApproval(
PatchSetApproval approval,
LabelAndScore ownersLabel,
Account.Id fileOwner,
Account.Id uploader) {
return ownersLabel.getLabelType().getLabelId().equals(approval.labelId())
&& isLabelApproved(
ownersLabel.getLabelType(), ownersLabel.getScore(), fileOwner, uploader, approval);
}
static boolean isLabelApproved(
LabelType label,
Optional<Short> score,
Account.Id fileOwner,
Account.Id uploader,
PatchSetApproval approval) {
if (label.isIgnoreSelfApproval() && fileOwner.equals(uploader)) {
return false;
}
return score
.map(value -> approval.value() >= value)
.orElseGet(
() -> {
LabelFunction function = label.getFunction();
if (function.isMaxValueRequired()) {
return label.isMaxPositive(approval);
}
if (function.isBlock() && label.isMaxNegative(approval)) {
return false;
}
return approval.value() > label.getDefaultValue();
});
}
static class LabelAndScore {
private final LabelType labelType;
private final Optional<Short> score;
LabelAndScore(LabelType labelType, Optional<Short> score) {
this.labelType = labelType;
this.score = score;
}
LabelType getLabelType() {
return labelType;
}
Optional<Short> getScore() {
return score;
}
}
private Map<String, FileDiffOutput> getDiff(Project.NameKey project, ObjectId revision)
throws DiffNotAvailableException {
requireNonNull(project, "project");
requireNonNull(revision, "revision");
// Use parentNum=0 to do the comparison against the default base.
// For non-merge commits the default base is the only parent (aka parent 1, initial commits
// are not supported).
// For merge commits the default base is the auto-merge commit which should be used as base IOW
// only the changes from it should be reviewed as changes against the parent 1 were already
// reviewed
return diffOperations.listModifiedFilesAgainstParent(
project, revision, 0, DiffOptions.DEFAULTS);
}
private static SubmitRecord notReady(String ownersLabel, String missingApprovals) {
SubmitRecord submitRecord = new SubmitRecord();
submitRecord.status = SubmitRecord.Status.NOT_READY;
submitRecord.errorMessage = missingApprovals;
submitRecord.requirements = List.of(SUBMIT_REQUIREMENT);
SubmitRecord.Label label = new SubmitRecord.Label();
label.label = String.format("%s from owners", ownersLabel);
label.status = SubmitRecord.Label.Status.NEED;
submitRecord.labels = List.of(label);
return submitRecord;
}
private static SubmitRecord ok() {
SubmitRecord submitRecord = new SubmitRecord();
submitRecord.status = SubmitRecord.Status.OK;
submitRecord.requirements = List.of(SUBMIT_REQUIREMENT);
return submitRecord;
}
private static SubmitRecord ruleError(String err) {
SubmitRecord submitRecord = new SubmitRecord();
submitRecord.status = SubmitRecord.Status.RULE_ERROR;
submitRecord.errorMessage = err;
return submitRecord;
}
}