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