blob: ac22453edd20ce668e3a3d67ed2ec5a6c3bbf45d [file] [log] [blame]
// Copyright (C) 2018 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.change;
import static com.google.common.base.Preconditions.checkState;
import static java.util.stream.Collectors.toList;
import com.google.auto.value.AutoValue;
import com.google.common.collect.HashBasedTable;
import com.google.common.collect.ImmutableMap;
import com.google.common.collect.Iterables;
import com.google.common.collect.LinkedHashMultimap;
import com.google.common.collect.Lists;
import com.google.common.collect.Maps;
import com.google.common.collect.MultimapBuilder;
import com.google.common.collect.SetMultimap;
import com.google.common.collect.Table;
import com.google.common.flogger.FluentLogger;
import com.google.common.primitives.Ints;
import com.google.gerrit.common.Nullable;
import com.google.gerrit.entities.Account;
import com.google.gerrit.entities.LabelType;
import com.google.gerrit.entities.LabelTypes;
import com.google.gerrit.entities.LabelValue;
import com.google.gerrit.entities.PatchSetApproval;
import com.google.gerrit.entities.SubmitRecord;
import com.google.gerrit.extensions.common.AccountInfo;
import com.google.gerrit.extensions.common.ApprovalInfo;
import com.google.gerrit.extensions.common.LabelInfo;
import com.google.gerrit.extensions.common.VotingRangeInfo;
import com.google.gerrit.server.ChangeUtil;
import com.google.gerrit.server.CurrentUser;
import com.google.gerrit.server.account.AccountLoader;
import com.google.gerrit.server.logging.Metadata;
import com.google.gerrit.server.logging.TraceContext;
import com.google.gerrit.server.logging.TraceContext.TraceTimer;
import com.google.gerrit.server.notedb.ReviewerStateInternal;
import com.google.gerrit.server.permissions.LabelPermission;
import com.google.gerrit.server.permissions.PermissionBackend;
import com.google.gerrit.server.permissions.PermissionBackendException;
import com.google.gerrit.server.project.DeleteVoteControl;
import com.google.gerrit.server.project.RemoveReviewerControl;
import com.google.gerrit.server.query.change.ChangeData;
import com.google.inject.Inject;
import com.google.inject.Singleton;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.TreeMap;
/**
* Produces label-related entities, like {@link LabelInfo}s, which is serialized to JSON afterwards.
*/
@Singleton
public class LabelsJson {
private static final FluentLogger logger = FluentLogger.forEnclosingClass();
private final PermissionBackend permissionBackend;
private final DeleteVoteControl deleteVoteControl;
private final RemoveReviewerControl removeReviewerControl;
@Inject
LabelsJson(
PermissionBackend permissionBackend,
DeleteVoteControl deleteVoteControl,
RemoveReviewerControl removeReviewerControl) {
this.permissionBackend = permissionBackend;
this.deleteVoteControl = deleteVoteControl;
this.removeReviewerControl = removeReviewerControl;
}
/**
* Returns all {@link LabelInfo}s for a single change. Uses the provided {@link AccountLoader} to
* lazily populate accounts. Callers have to call {@link AccountLoader#fill()} afterwards to
* populate all accounts in the returned {@link LabelInfo}s.
*/
@Nullable
Map<String, LabelInfo> labelsFor(
AccountLoader accountLoader, ChangeData cd, boolean standard, boolean detailed)
throws PermissionBackendException {
if (!standard && !detailed) {
return null;
}
try (TraceTimer timer =
TraceContext.newTimer(
"Get labels", Metadata.builder().changeId(cd.change().getId().get()).build())) {
LabelTypes labelTypes = cd.getLabelTypes();
Map<String, LabelWithStatus> withStatus =
cd.change().isMerged()
? labelsForSubmittedChange(accountLoader, cd, labelTypes, standard, detailed)
: labelsForUnsubmittedChange(accountLoader, cd, labelTypes, standard, detailed);
return ImmutableMap.copyOf(Maps.transformValues(withStatus, LabelWithStatus::label));
}
}
/**
* Returns A map of all label names and the values that the provided user has permission to vote
* on.
*
* @param filterApprovalsBy a Gerrit user ID.
* @param cd {@link ChangeData} corresponding to a specific gerrit change.
* @return A Map where the key contain a label name, and the value is a list of the permissible
* vote values that the user can vote on.
*/
Map<String, Collection<String>> permittedLabels(Account.Id filterApprovalsBy, ChangeData cd)
throws PermissionBackendException {
try (TraceTimer timer =
TraceContext.newTimer(
"Get permitted labels",
Metadata.builder().changeId(cd.change().getId().get()).build())) {
SetMultimap<String, String> permitted = LinkedHashMultimap.create();
boolean isMerged = cd.change().isMerged();
Map<String, Short> currentUserVotes = currentLabels(filterApprovalsBy, cd);
for (LabelType labelType : cd.getLabelTypes().getLabelTypes()) {
if (isMerged && !labelType.isAllowPostSubmit()) {
continue;
}
Set<LabelPermission.WithValue> can =
permissionBackend.absentUser(filterApprovalsBy).change(cd).test(labelType);
for (LabelValue v : labelType.getValues()) {
boolean ok = can.contains(new LabelPermission.WithValue(labelType, v));
if (isMerged) {
// Votes cannot be decreased if the change is merged. Only accept the label value if
// it's
// greater or equal than the user's latest vote.
short prev = currentUserVotes.getOrDefault(labelType.getName(), (short) 0);
ok &= v.getValue() >= prev;
}
if (ok) {
permitted.put(labelType.getName(), v.formatValue());
}
}
}
clearOnlyZerosEntries(permitted);
return permitted.asMap();
}
}
/**
* Returns A map of all labels that the provided user has permission to remove.
*
* @param accountLoader to load the reviewers' data with.
* @param user a Gerrit user.
* @param cd {@link ChangeData} corresponding to a specific gerrit change.
* @return A Map of {@code labelName} -> {Map of {@code value} -> List of {@link AccountInfo}}
* that the user can remove votes from.
*/
Map<String, Map<String, List<AccountInfo>>> removableLabels(
AccountLoader accountLoader, CurrentUser user, ChangeData cd)
throws PermissionBackendException {
try (TraceTimer timer =
TraceContext.newTimer(
"Get removable labels",
Metadata.builder().changeId(cd.change().getId().get()).build())) {
if (cd.change().isMerged()) {
return new HashMap<>();
}
Map<String, Map<String, List<AccountInfo>>> res = new HashMap<>();
LabelTypes labelTypes = cd.getLabelTypes();
for (PatchSetApproval approval : cd.currentApprovals()) {
Optional<LabelType> labelType = labelTypes.byLabel(approval.labelId());
if (!labelType.isPresent()) {
continue;
}
if (!(deleteVoteControl.testDeleteVotePermissions(user, cd, approval, labelType.get())
|| removeReviewerControl.testRemoveReviewer(
cd, user, approval.accountId(), approval.value()))) {
continue;
}
if (!res.containsKey(approval.label())) {
res.put(approval.label(), new HashMap<>());
}
String labelValue = LabelValue.formatValue(approval.value());
if (!res.get(approval.label()).containsKey(labelValue)) {
res.get(approval.label()).put(labelValue, new ArrayList<>());
}
res.get(approval.label()).get(labelValue).add(accountLoader.get(approval.accountId()));
}
return res;
}
}
private static void clearOnlyZerosEntries(SetMultimap<String, String> permitted) {
List<String> toClear = Lists.newArrayListWithCapacity(permitted.keySet().size());
for (Map.Entry<String, Collection<String>> e : permitted.asMap().entrySet()) {
if (isOnlyZero(e.getValue())) {
toClear.add(e.getKey());
}
}
for (String label : toClear) {
permitted.removeAll(label);
}
}
private static boolean isOnlyZero(Collection<String> values) {
return values.isEmpty() || (values.size() == 1 && values.contains(" 0"));
}
private static void addApproval(LabelInfo label, ApprovalInfo approval) {
if (label.all == null) {
label.all = new ArrayList<>();
}
label.all.add(approval);
}
private Map<String, LabelWithStatus> labelsForUnsubmittedChange(
AccountLoader accountLoader,
ChangeData cd,
LabelTypes labelTypes,
boolean standard,
boolean detailed)
throws PermissionBackendException {
Map<String, LabelWithStatus> labels =
initLabels(accountLoader, cd, labelTypes, /*includeAccountInfo=*/ standard || detailed);
setAllApprovals(accountLoader, cd, labels, detailed);
for (Map.Entry<String, LabelWithStatus> e : labels.entrySet()) {
Optional<LabelType> type = labelTypes.byLabel(e.getKey());
if (!type.isPresent()) {
continue;
}
if (standard || detailed) {
for (PatchSetApproval psa : cd.currentApprovals()) {
if (type.get().matches(psa)) {
short val = psa.value();
Account.Id accountId = psa.accountId();
setLabelScores(accountLoader, type.get(), e.getValue(), val, accountId);
}
}
}
setLabelValues(type.get(), e.getValue());
}
return labels;
}
private Integer parseRangeValue(String value) {
if (value.startsWith("+")) {
value = value.substring(1);
} else if (value.startsWith(" ")) {
value = value.trim();
}
return Ints.tryParse(value);
}
private ApprovalInfo approvalInfo(
AccountLoader accountLoader,
Account.Id id,
@Nullable Integer value,
@Nullable VotingRangeInfo permittedVotingRange,
@Nullable String tag,
@Nullable Instant date) {
ApprovalInfo ai = new ApprovalInfo(id.get(), value, permittedVotingRange, tag, date);
accountLoader.put(ai);
return ai;
}
private void setLabelValues(LabelType type, LabelWithStatus l) {
l.label().defaultValue = type.getDefaultValue();
l.label().values = new LinkedHashMap<>();
for (LabelValue v : type.getValues()) {
l.label().values.put(v.formatValue(), v.getText());
}
if (isOnlyZero(l.label().values.keySet())) {
l.label().values = null;
}
}
private Map<String, Short> currentLabels(@Nullable Account.Id accountId, ChangeData cd) {
Map<String, Short> result = new HashMap<>();
for (PatchSetApproval psa : cd.currentApprovals()) {
if (accountId == null || psa.accountId().equals(accountId)) {
result.put(psa.label(), psa.value());
}
}
return result;
}
private Map<String, LabelWithStatus> labelsForSubmittedChange(
AccountLoader accountLoader,
ChangeData cd,
LabelTypes labelTypes,
boolean standard,
boolean detailed)
throws PermissionBackendException {
Set<Account.Id> allUsers = new HashSet<>();
if (detailed) {
// Users expect to see all reviewers on closed changes, even if they
// didn't vote on the latest patch set. If we don't need detailed labels,
// we aren't including 0 votes for all users below, so we can just look at
// the latest patch set (in the next loop).
for (PatchSetApproval psa : cd.approvals().values()) {
allUsers.add(psa.accountId());
}
}
Set<String> labelNames = new HashSet<>();
SetMultimap<Account.Id, PatchSetApproval> current =
MultimapBuilder.hashKeys().hashSetValues().build();
for (PatchSetApproval a : cd.currentApprovals()) {
allUsers.add(a.accountId());
Optional<LabelType> type = labelTypes.byLabel(a.labelId());
if (type.isPresent()) {
labelNames.add(type.get().getName());
// Not worth the effort to distinguish between votable/non-votable for 0
// values on closed changes, since they can't vote anyway.
current.put(a.accountId(), a);
}
}
// Since voting on merged changes is allowed all labels which apply to
// the change must be returned. All applying labels can be retrieved from
// the submit records, which is what initLabels does.
// It's not possible to only compute the labels based on the approvals
// since merged changes may not have approvals for all labels (e.g. if not
// all labels are required for submit or if the change was auto-closed due
// to direct push or if new labels were defined after the change was
// merged).
Map<String, LabelWithStatus> labels;
labels = initLabels(accountLoader, cd, labelTypes, standard);
// Also include all labels for which approvals exists. E.g. there can be
// approvals for labels that are ignored by a Prolog submit rule and hence
// it wouldn't be included in the submit records.
for (String name : labelNames) {
if (!labels.containsKey(name)) {
labels.put(name, LabelWithStatus.create(new LabelInfo(), null));
}
}
labels.entrySet().stream()
.filter(e -> labelTypes.byLabel(e.getKey()).isPresent())
.forEach(e -> setLabelValues(labelTypes.byLabel(e.getKey()).get(), e.getValue()));
for (Account.Id accountId : allUsers) {
Map<String, ApprovalInfo> byLabel = Maps.newHashMapWithExpectedSize(labels.size());
Map<String, VotingRangeInfo> pvr = Collections.emptyMap();
if (detailed) {
pvr = getPermittedVotingRanges(permittedLabels(accountId, cd));
}
for (Map.Entry<String, LabelWithStatus> entry : labels.entrySet()) {
ApprovalInfo ai = approvalInfo(accountLoader, accountId, 0, null, null, null);
byLabel.put(entry.getKey(), ai);
addApproval(entry.getValue().label(), ai);
}
for (PatchSetApproval psa : current.get(accountId)) {
Optional<LabelType> type = labelTypes.byLabel(psa.labelId());
if (!type.isPresent()) {
continue;
}
short val = psa.value();
ApprovalInfo info = byLabel.get(type.get().getName());
if (info != null) {
info.value = Integer.valueOf(val);
info.permittedVotingRange = pvr.getOrDefault(type.get().getName(), null);
info.setDate(psa.granted());
info.tag = psa.tag().orElse(null);
if (psa.postSubmit()) {
info.postSubmit = true;
}
}
if (!standard) {
continue;
}
setLabelScores(accountLoader, type.get(), labels.get(type.get().getName()), val, accountId);
}
}
return labels;
}
private Map<String, LabelWithStatus> initLabels(
AccountLoader accountLoader,
ChangeData cd,
LabelTypes labelTypes,
boolean includeAccountInfo) {
Map<String, LabelWithStatus> labels = new TreeMap<>(labelTypes.nameComparator());
for (SubmitRecord rec : submitRecords(cd)) {
if (rec.labels == null) {
continue;
}
for (SubmitRecord.Label r : rec.labels) {
LabelWithStatus p = labels.get(r.label);
if (p == null || p.status().compareTo(r.status) < 0) {
LabelInfo n = new LabelInfo();
if (includeAccountInfo) {
switch (r.status) {
case OK:
n.approved = accountLoader.get(r.appliedBy);
break;
case REJECT:
n.rejected = accountLoader.get(r.appliedBy);
n.blocking = true;
break;
case IMPOSSIBLE:
case MAY:
case NEED:
default:
break;
}
}
n.optional = r.status == SubmitRecord.Label.Status.MAY ? true : null;
labels.put(r.label, LabelWithStatus.create(n, r.status));
}
}
}
setLabelsDescription(labels, labelTypes);
return labels;
}
private void setLabelsDescription(
Map<String, LabelsJson.LabelWithStatus> labels, LabelTypes labelTypes) {
for (Map.Entry<String, LabelWithStatus> entry : labels.entrySet()) {
String labelName = entry.getKey();
Optional<LabelType> type = labelTypes.byLabel(labelName);
if (!type.isPresent()) {
continue;
}
LabelWithStatus labelWithStatus = entry.getValue();
labelWithStatus.label().description = type.get().getDescription().orElse(null);
}
}
private void setLabelScores(
AccountLoader accountLoader,
LabelType type,
LabelWithStatus l,
short score,
Account.Id accountId) {
if (l.label().approved != null || l.label().rejected != null) {
return;
}
if (type.getMin() == null || type.getMax() == null) {
// Can't set score for unknown or misconfigured type.
return;
}
if (score != 0) {
if (score == type.getMin().getValue()) {
l.label().rejected = accountLoader.get(accountId);
} else if (score == type.getMax().getValue()) {
l.label().approved = accountLoader.get(accountId);
} else if (score < 0) {
l.label().disliked = accountLoader.get(accountId);
l.label().value = score;
} else if (score > 0 && l.label().disliked == null) {
l.label().recommended = accountLoader.get(accountId);
l.label().value = score;
}
}
}
private void setAllApprovals(
AccountLoader accountLoader,
ChangeData cd,
Map<String, LabelWithStatus> labels,
boolean detailed)
throws PermissionBackendException {
checkState(
!cd.change().isMerged(),
"should not call setAllApprovals on %s change",
ChangeUtil.status(cd.change()));
// Include a user in the output for this label if either:
// - They are an explicit reviewer.
// - They ever voted on this change.
Set<Account.Id> allUsers = new HashSet<>();
allUsers.addAll(cd.reviewers().byState(ReviewerStateInternal.REVIEWER));
for (PatchSetApproval psa : cd.approvals().values()) {
allUsers.add(psa.accountId());
}
Table<Account.Id, String, PatchSetApproval> current =
HashBasedTable.create(allUsers.size(), cd.getLabelTypes().getLabelTypes().size());
for (PatchSetApproval psa : cd.currentApprovals()) {
current.put(psa.accountId(), psa.label(), psa);
}
LabelTypes labelTypes = cd.getLabelTypes();
for (Account.Id accountId : allUsers) {
Map<String, VotingRangeInfo> pvr = null;
PermissionBackend.ForChange perm = null;
if (detailed) {
perm = permissionBackend.absentUser(accountId).change(cd);
pvr = getPermittedVotingRanges(permittedLabels(accountId, cd));
}
for (Map.Entry<String, LabelWithStatus> e : labels.entrySet()) {
Optional<LabelType> lt = labelTypes.byLabel(e.getKey());
if (!lt.isPresent()) {
// Ignore submit record for undefined label; likely the submit rule
// author didn't intend for the label to show up in the table.
continue;
}
Integer value;
VotingRangeInfo permittedVotingRange =
pvr == null ? null : pvr.getOrDefault(lt.get().getName(), null);
String tag = null;
Instant date = null;
PatchSetApproval psa = current.get(accountId, lt.get().getName());
if (psa != null) {
value = Integer.valueOf(psa.value());
if (value == 0) {
// This may be a dummy approval that was inserted when the reviewer
// was added. Explicitly check whether the user can vote on this
// label.
value = perm != null && perm.test(new LabelPermission(lt.get())) ? 0 : null;
}
tag = psa.tag().orElse(null);
date = psa.granted();
if (psa.postSubmit()) {
logger.atWarning().log("unexpected post-submit approval on open change: %s", psa);
}
} else {
// Either the user cannot vote on this label, or they were added as a
// reviewer but have not responded yet. Explicitly check whether the
// user can vote on this label.
value = perm != null && perm.test(new LabelPermission(lt.get())) ? 0 : null;
}
addApproval(
e.getValue().label(),
approvalInfo(accountLoader, accountId, value, permittedVotingRange, tag, date));
}
}
}
private List<SubmitRecord> submitRecords(ChangeData cd) {
return cd.submitRecords(ChangeJson.SUBMIT_RULE_OPTIONS_LENIENT);
}
private Map<String, VotingRangeInfo> getPermittedVotingRanges(
Map<String, Collection<String>> permittedLabels) {
Map<String, VotingRangeInfo> permittedVotingRanges =
Maps.newHashMapWithExpectedSize(permittedLabels.size());
for (String label : permittedLabels.keySet()) {
List<Integer> permittedVotingRange =
permittedLabels.get(label).stream()
.map(this::parseRangeValue)
.filter(java.util.Objects::nonNull)
.sorted()
.collect(toList());
if (permittedVotingRange.isEmpty()) {
permittedVotingRanges.put(label, null);
} else {
int minPermittedValue = permittedVotingRange.get(0);
int maxPermittedValue = Iterables.getLast(permittedVotingRange);
permittedVotingRanges.put(label, new VotingRangeInfo(minPermittedValue, maxPermittedValue));
}
}
return permittedVotingRanges;
}
@AutoValue
abstract static class LabelWithStatus {
private static LabelWithStatus create(LabelInfo label, SubmitRecord.Label.Status status) {
return new AutoValue_LabelsJson_LabelWithStatus(label, status);
}
abstract LabelInfo label();
@Nullable
abstract SubmitRecord.Label.Status status();
}
}