All Downloads are FREE. Search and download functionalities are using the official Maven repository.

com.google.gerrit.server.approval.ApprovalsUtil Maven / Gradle / Ivy

There is a newer version: 3.11.0-rc3
Show newest version
// Copyright (C) 2009 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.approval;

import static com.google.common.base.Preconditions.checkArgument;
import static com.google.common.collect.ImmutableList.toImmutableList;
import static com.google.common.collect.ImmutableListMultimap.toImmutableListMultimap;
import static com.google.gerrit.server.notedb.ReviewerStateInternal.CC;
import static com.google.gerrit.server.notedb.ReviewerStateInternal.REVIEWER;
import static com.google.gerrit.server.project.ProjectCache.illegalState;
import static java.util.Comparator.comparing;
import static java.util.Objects.requireNonNull;
import static java.util.stream.Collectors.joining;

import com.google.common.annotations.VisibleForTesting;
import com.google.common.collect.ArrayListMultimap;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableListMultimap;
import com.google.common.collect.ImmutableSet;
import com.google.common.collect.Iterables;
import com.google.common.collect.ListMultimap;
import com.google.common.collect.Lists;
import com.google.common.collect.Multimap;
import com.google.common.collect.Multimaps;
import com.google.common.collect.Sets;
import com.google.common.flogger.FluentLogger;
import com.google.gerrit.entities.Account;
import com.google.gerrit.entities.AttentionSetUpdate;
import com.google.gerrit.entities.Change;
import com.google.gerrit.entities.LabelId;
import com.google.gerrit.entities.LabelType;
import com.google.gerrit.entities.LabelTypes;
import com.google.gerrit.entities.PatchSet;
import com.google.gerrit.entities.PatchSetApproval;
import com.google.gerrit.entities.PatchSetInfo;
import com.google.gerrit.exceptions.StorageException;
import com.google.gerrit.extensions.restapi.AuthException;
import com.google.gerrit.extensions.restapi.BadRequestException;
import com.google.gerrit.extensions.restapi.RestApiException;
import com.google.gerrit.index.query.QueryParseException;
import com.google.gerrit.server.CurrentUser;
import com.google.gerrit.server.ReviewerSet;
import com.google.gerrit.server.ReviewerStatusUpdate;
import com.google.gerrit.server.account.AccountCache;
import com.google.gerrit.server.change.LabelNormalizer;
import com.google.gerrit.server.config.AnonymousCowardName;
import com.google.gerrit.server.notedb.ChangeNotes;
import com.google.gerrit.server.notedb.ChangeUpdate;
import com.google.gerrit.server.notedb.ReviewerStateInternal;
import com.google.gerrit.server.permissions.ChangePermission;
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.ProjectCache;
import com.google.gerrit.server.query.approval.ApprovalQueryBuilder;
import com.google.gerrit.server.query.approval.UserInPredicate;
import com.google.gerrit.server.util.AccountTemplateUtil;
import com.google.gerrit.server.util.LabelVote;
import com.google.gerrit.server.util.ManualRequestContext;
import com.google.gerrit.server.util.OneOffRequestContext;
import com.google.inject.Inject;
import com.google.inject.Provider;
import com.google.inject.Singleton;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.HashSet;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import org.eclipse.jgit.lib.Config;
import org.eclipse.jgit.revwalk.RevWalk;

/**
 * Utility functions to manipulate patchset approvals.
 *
 * 

Approvals are overloaded, they represent both approvals and reviewers which should be CCed on * a change. To ensure that reviewers are not lost there must always be an approval on each patchset * for each reviewer, even if the reviewer hasn't actually given a score to the change. To mark the * "no score" case, a dummy approval, which may live in any of the available categories, with a * score of 0 is used. */ @Singleton public class ApprovalsUtil { private static final FluentLogger logger = FluentLogger.forEnclosingClass(); public static PatchSetApproval.Builder newApproval( PatchSet.Id psId, CurrentUser user, LabelId labelId, int value, Instant when) { PatchSetApproval.Builder b = PatchSetApproval.builder() .key(PatchSetApproval.key(psId, user.getAccountId(), labelId)) .value(value) .granted(when); user.updateRealAccountId(b::realAccountId); return b; } private static Iterable filterApprovals( Iterable psas, Account.Id accountId) { return Iterables.filter(psas, a -> Objects.equals(a.accountId(), accountId)); } private final AccountCache accountCache; private final String anonymousCowardName; private final ApprovalCopier approvalCopier; private final Provider approvalQueryBuilderProvider; private final PermissionBackend permissionBackend; private final ProjectCache projectCache; private final LabelNormalizer labelNormalizer; private final OneOffRequestContext requestContext; @VisibleForTesting @Inject public ApprovalsUtil( AccountCache accountCache, @AnonymousCowardName String anonymousCowardName, ApprovalCopier approvalCopier, Provider approvalQueryBuilderProvider, PermissionBackend permissionBackend, ProjectCache projectCache, LabelNormalizer labelNormalizer, OneOffRequestContext requestContext) { this.accountCache = accountCache; this.anonymousCowardName = anonymousCowardName; this.approvalCopier = approvalCopier; this.approvalQueryBuilderProvider = approvalQueryBuilderProvider; this.permissionBackend = permissionBackend; this.projectCache = projectCache; this.labelNormalizer = labelNormalizer; this.requestContext = requestContext; } /** * Get all reviewers for a change. * * @param notes change notes. * @return reviewers for the change. */ public ReviewerSet getReviewers(ChangeNotes notes) { return notes.load().getReviewers(); } /** * Get updates to reviewer set. * * @param notes change notes. * @return reviewer updates for the change. */ public List getReviewerUpdates(ChangeNotes notes) { return notes.load().getReviewerUpdates(); } public List addReviewers( ChangeUpdate update, LabelTypes labelTypes, Change change, PatchSet ps, PatchSetInfo info, Iterable wantReviewers, Collection existingReviewers) { return addReviewers( update, labelTypes, change, ps.id(), info.getAuthor().getAccount(), info.getCommitter().getAccount(), wantReviewers, existingReviewers); } public List addReviewers( ChangeNotes notes, ChangeUpdate update, LabelTypes labelTypes, Change change, Iterable wantReviewers) { PatchSet.Id psId = change.currentPatchSetId(); Collection existingReviewers; existingReviewers = notes.load().getReviewers().byState(REVIEWER); // Existing reviewers should include pending additions in the REVIEWER // state, taken from ChangeUpdate. existingReviewers = Lists.newArrayList(existingReviewers); for (Map.Entry entry : update.getReviewers().entrySet()) { if (entry.getValue() == REVIEWER) { existingReviewers.add(entry.getKey()); } } return addReviewers( update, labelTypes, change, psId, null, null, wantReviewers, existingReviewers); } private List addReviewers( ChangeUpdate update, LabelTypes labelTypes, Change change, PatchSet.Id psId, Account.Id authorId, Account.Id committerId, Iterable wantReviewers, Collection existingReviewers) { List allTypes = labelTypes.getLabelTypes(); if (allTypes.isEmpty()) { return ImmutableList.of(); } Set need = Sets.newLinkedHashSet(wantReviewers); if (authorId != null && canSee(update.getNotes(), authorId)) { need.add(authorId); } if (committerId != null && canSee(update.getNotes(), committerId)) { need.add(committerId); } need.remove(change.getOwner()); need.removeAll(existingReviewers); if (need.isEmpty()) { return ImmutableList.of(); } List cells = Lists.newArrayListWithCapacity(need.size()); LabelId labelId = Iterables.getLast(allTypes).getLabelId(); for (Account.Id account : need) { cells.add( PatchSetApproval.builder() .key(PatchSetApproval.key(psId, account, labelId)) .value(0) .granted(update.getWhen()) .build()); update.putReviewer(account, REVIEWER); } return Collections.unmodifiableList(cells); } private boolean canSee(ChangeNotes notes, Account.Id accountId) { try { if (!projectCache .get(notes.getProjectName()) .orElseThrow(illegalState(notes.getProjectName())) .statePermitsRead()) { return false; } return permissionBackend.absentUser(accountId).change(notes).test(ChangePermission.READ); } catch (PermissionBackendException e) { logger.atWarning().withCause(e).log( "Failed to check if account %d can see change %d", accountId.get(), notes.getChangeId().get()); return false; } } /** * Adds accounts to a change as reviewers in the CC state. * * @param notes change notes. * @param update change update. * @param wantCCs accounts to CC. * @param keepExistingReviewers whether provided accounts that are already reviewer should be kept * as reviewer or be downgraded to CC * @return whether a change was made. */ public Collection addCcs( ChangeNotes notes, ChangeUpdate update, Collection wantCCs, boolean keepExistingReviewers) { return addCcs(update, wantCCs, notes.load().getReviewers(), keepExistingReviewers); } private Collection addCcs( ChangeUpdate update, Collection wantCCs, ReviewerSet existingReviewers, boolean keepExistingReviewers) { Set need = new LinkedHashSet<>(wantCCs); need.removeAll(existingReviewers.byState(CC)); if (keepExistingReviewers) { need.removeAll(existingReviewers.byState(REVIEWER)); } need.removeAll(update.getReviewers().keySet()); for (Account.Id account : need) { update.putReviewer(account, CC); } return need; } /** * Adds approvals to ChangeUpdate for a new patch set, and writes to NoteDb. * * @param update change update. * @param labelTypes label types for the containing project. * @param ps patch set being approved. * @param user user adding approvals. * @param approvals approvals to add. */ public Iterable addApprovalsForNewPatchSet( ChangeUpdate update, LabelTypes labelTypes, PatchSet ps, CurrentUser user, Map approvals) throws RestApiException, PermissionBackendException { Account.Id accountId = user.getAccountId(); checkArgument( accountId.equals(ps.uploader()), "expected user %s to match patch set uploader %s", accountId, ps.uploader()); if (approvals.isEmpty()) { return ImmutableList.of(); } checkApprovals(approvals, permissionBackend.user(user).change(update.getNotes())); List cells = new ArrayList<>(approvals.size()); Instant ts = update.getWhen(); for (Map.Entry vote : approvals.entrySet()) { Optional lt = labelTypes.byLabel(vote.getKey()); if (!lt.isPresent()) { throw new BadRequestException( String.format("label \"%s\" is not a configured label", vote.getKey())); } cells.add(newApproval(ps.id(), user, lt.get().getLabelId(), vote.getValue(), ts).build()); } for (PatchSetApproval psa : cells) { update.putApproval(psa.label(), psa.value()); } return cells; } public static void checkLabel(LabelTypes labelTypes, String name, Short value) throws BadRequestException { Optional label = labelTypes.byLabel(name); if (!label.isPresent()) { throw new BadRequestException(String.format("label \"%s\" is not a configured label", name)); } if (label.get().getValue(value) == null) { throw new BadRequestException( String.format("label \"%s\": %d is not a valid value", name, value)); } } private static void checkApprovals( Map approvals, PermissionBackend.ForChange forChange) throws AuthException, PermissionBackendException { for (Map.Entry vote : approvals.entrySet()) { String name = vote.getKey(); Short value = vote.getValue(); if (!forChange.test(new LabelPermission.WithValue(name, value))) { throw new AuthException( String.format("applying label \"%s\": %d is restricted", name, value)); } } } public ListMultimap byChangeExcludingCopiedApprovals( ChangeNotes notes) { return notes.load().getApprovals().onlyNonCopied(); } /** * Copies approvals to a new patch set. * *

Computes the approvals of the prior patch set that should be copied to the new patch set and * stores them in NoteDb. * *

For outdated approvals (approvals on the prior patch set which are outdated by the new patch * set and hence not copied) the approvers are added to the attention set since they need to * re-review the change and renew their approvals. * * @param notes the change notes * @param patchSet the newly created patch set * @param revWalk {@link RevWalk} that can see the new patch set revision * @param repoConfig the repo config * @param changeUpdate changeUpdate that is used to persist the copied approvals and update the * attention set * @return the result of the approval copying */ public ApprovalCopier.Result copyApprovalsToNewPatchSet( ChangeNotes notes, PatchSet patchSet, RevWalk revWalk, Config repoConfig, ChangeUpdate changeUpdate) { ApprovalCopier.Result approvalCopierResult = approvalCopier.forPatchSet(notes, patchSet, revWalk, repoConfig); approvalCopierResult.copiedApprovals().forEach(a -> changeUpdate.putCopiedApproval(a)); if (!notes.getChange().isWorkInProgress()) { // The attention set should not be updated when the change is work-in-progress. addAttentionSetUpdatesForOutdatedApprovals( changeUpdate, approvalCopierResult.outdatedApprovals()); } return approvalCopierResult; } private void addAttentionSetUpdatesForOutdatedApprovals( ChangeUpdate changeUpdate, ImmutableSet outdatedApprovals) { Set updates = new HashSet<>(); Multimap outdatedApprovalsByUser = ArrayListMultimap.create(); outdatedApprovals.forEach(psa -> outdatedApprovalsByUser.put(psa.accountId(), psa)); for (Map.Entry> e : outdatedApprovalsByUser.asMap().entrySet()) { Account.Id approverId = e.getKey(); Collection outdatedUserApprovals = e.getValue(); String message; if (outdatedUserApprovals.size() == 1) { PatchSetApproval outdatedUserApproval = Iterables.getOnlyElement(outdatedUserApprovals); message = String.format( "Vote got outdated and was removed: %s", LabelVote.create(outdatedUserApproval.label(), outdatedUserApproval.value()) .format()); } else { message = String.format( "Votes got outdated and were removed: %s", outdatedUserApprovals.stream() .map( outdatedUserApproval -> LabelVote.create( outdatedUserApproval.label(), outdatedUserApproval.value()) .format()) .sorted() .collect(joining(", "))); } updates.add( AttentionSetUpdate.createForWrite(approverId, AttentionSetUpdate.Operation.ADD, message)); } changeUpdate.addToPlannedAttentionSetUpdates(updates); } public Optional formatApprovalCopierResult( ApprovalCopier.Result approvalCopierResult, LabelTypes labelTypes) { requireNonNull(approvalCopierResult, "approvalCopierResult"); requireNonNull(labelTypes, "labelTypes"); if (approvalCopierResult.copiedApprovals().isEmpty() && approvalCopierResult.outdatedApprovals().isEmpty()) { return Optional.empty(); } StringBuilder message = new StringBuilder(); if (!approvalCopierResult.copiedApprovals().isEmpty()) { message.append("Copied Votes:\n"); message.append( formatApprovalListWithCopyCondition(approvalCopierResult.copiedApprovals(), labelTypes)); } if (!approvalCopierResult.outdatedApprovals().isEmpty()) { if (!approvalCopierResult.copiedApprovals().isEmpty()) { message.append("\n"); } message.append("Outdated Votes:\n"); message.append( formatApprovalListWithCopyCondition( approvalCopierResult.outdatedApprovals(), labelTypes)); } return Optional.of(message.toString()); } /** * Formats the given approvals as a bullet list, each approval with the corresponding copy * condition if available. * *

E.g.: * *

   * * Code-Review+1, Code-Review+2 (copy condition: "is:MIN")
   * * Verified+1 (copy condition: "is:MIN")
   * 
* *

Entries in the list can have the following formats: * *

    *
  • {@code (copy condition: * "")} (if a copy condition without UserInPredicate * is present), e.g.: {@code Code-Review+1, Code-Review+2 (copy condition: "is:MIN")} *
  • {@code by (copy condition: * "")} (if a copy condition with UserInPredicate is * present), e.g. {@code Code-Review+1 by , * (copy condition: "approverin:7d9e2d5b561e75230e4463ae757ac5d6ff715d85")} *
  • {@code } (if no copy condition is * present), e.g.: {@code Code-Review+1, Code-Review+2} *
  • {@code (label type is missing)} (if * the label type is missing), e.g.: {@code Code-Review+1, Code-Review+2 (label type is * missing)} *
  • {@code (non-parseable copy * condition: "")} (if a non-parseable copy condition is * present), e.g.: {@code Code-Review+1, Code-Review+2 (non-parseable copy condition: * "is:FOO")} *
* * @param approvals the approvals that should be formatted * @param labelTypes the label types * @return bullet list with the formatted approvals */ private String formatApprovalListWithCopyCondition( ImmutableSet approvals, LabelTypes labelTypes) { StringBuilder message = new StringBuilder(); // sort approvals by label vote so that we list them in a deterministic order ImmutableList approvalsSortedByLabelVote = approvals.stream() .sorted(comparing(psa -> LabelVote.create(psa.label(), psa.value()).format())) .collect(toImmutableList()); ImmutableListMultimap approvalsByLabel = Multimaps.index(approvalsSortedByLabelVote, PatchSetApproval::label); for (Map.Entry> approvalsByLabelEntry : approvalsByLabel.asMap().entrySet()) { String label = approvalsByLabelEntry.getKey(); Collection approvalsForSameLabel = approvalsByLabelEntry.getValue(); message.append("* "); if (!labelTypes.byLabel(label).isPresent()) { message .append(formatApprovalsAsLabelVotesList(approvalsForSameLabel)) .append(" (label type is missing)\n"); continue; } LabelType labelType = labelTypes.byLabel(label).get(); if (!labelType.getCopyCondition().isPresent()) { message.append(formatApprovalsAsLabelVotesList(approvalsForSameLabel)).append("\n"); continue; } message .append( formatApprovalsWithCopyCondition( approvalsForSameLabel, labelType.getCopyCondition().get())) .append("\n"); } return message.toString(); } /** * Formats the given approvals of the same label with the given copy condition. * *

E.g.: {Code-Review+1, Code-Review+2 (copy condition: "is:MIN")} * *

The following format may be returned: * *

    *
  • {@code (copy condition: * "")} (if a copy condition without UserInPredicate * is present), e.g.: {@code Code-Review+1, Code-Review+2 (copy condition: "is:MIN")} *
  • {@code by (copy condition: * "")} (if a copy condition with UserInPredicate is * present), e.g. {@code Code-Review+1 by , * (copy condition: "approverin:7d9e2d5b561e75230e4463ae757ac5d6ff715d85")} *
  • {@code (non-parseable copy * condition: "")} (if a non-parseable copy condition is * present), e.g.: {@code Code-Review+1, Code-Review+2 (non-parseable copy condition: * "is:FOO")} *
* * @param approvalsForSameLabel the approvals that should be formatted, must be for the same label * @param copyCondition the copy condition of the label * @return the formatted approvals */ private String formatApprovalsWithCopyCondition( Collection approvalsForSameLabel, String copyCondition) { StringBuilder message = new StringBuilder(); boolean containsUserInPredicate; try { containsUserInPredicate = containsUserInPredicate(copyCondition); } catch (QueryParseException e) { logger.atWarning().withCause(e).log("Non-parsable query condition"); message.append(formatApprovalsAsLabelVotesList(approvalsForSameLabel)); message.append(String.format(" (non-parseable copy condition: \"%s\")", copyCondition)); return message.toString(); } if (containsUserInPredicate) { // If a UserInPredicate is used (e.g. 'approverin:' or 'uploaderin:') we need to // include the approvers into the change message since they are relevant for the matching. For // example it can happen that the same approval of different users is copied for the one user // but not for the other user (since the one user is a member of the approverin group and the // other user isn't). // // Example: // * label Foo has the copy condition 'is:ANY approverin:123' // * group 123 contains UserA as member, but not UserB // * a change has the following approvals: Foo+1 by UserA and Foo+1 by UserB // // In this case Foo+1 by UserA is copied because UserA is a member of group 123 and the copy // condition matches, while Foo+1 by UserB is not copied because UserB is not a member of // group 123 and the copy condition doesn't match. // // So it can happen that the same approval Foo+1, but by different users, is copied and // outdated at the same time. To allow users to understand that the copying depends on who did // the approval, the approvers must be included into the change message. // sort the approvals by their approvers name-email so that the approvers always appear in a // deterministic order ImmutableList approvalsSortedByLabelVoteAndApprover = approvalsForSameLabel.stream() .sorted( comparing( (PatchSetApproval psa) -> LabelVote.create(psa.label(), psa.value()).format()) .thenComparing( psa -> accountCache .getEvenIfMissing(psa.accountId()) .account() .getNameEmail(anonymousCowardName))) .collect(toImmutableList()); ImmutableListMultimap approversByLabelVote = Multimaps.index( approvalsSortedByLabelVoteAndApprover, psa -> LabelVote.create(psa.label(), psa.value())) .entries().stream() .collect(toImmutableListMultimap(e -> e.getKey(), e -> e.getValue().accountId())); message.append( approversByLabelVote.asMap().entrySet().stream() .map( approversByLabelVoteEntry -> formatLabelVoteWithApprovers( approversByLabelVoteEntry.getKey(), approversByLabelVoteEntry.getValue())) .collect(joining(", "))); } else { // copy condition doesn't contain a UserInPredicate message.append(formatApprovalsAsLabelVotesList(approvalsForSameLabel)); } message.append(String.format(" (copy condition: \"%s\")", copyCondition)); return message.toString(); } private boolean containsUserInPredicate(String copyCondition) throws QueryParseException { // Use a request context to run checks as an internal user with expanded visibility. This is // so that the output of the copy condition does not depend on who is running the current // request (e.g. a group used in this query might not be visible to the person sending this // request). try (ManualRequestContext ignored = requestContext.open()) { return approvalQueryBuilderProvider.get().parse(copyCondition).getFlattenedPredicateList() .stream() .anyMatch(UserInPredicate.class::isInstance); } } /** * Formats the given approvals as a comma-separated list of label votes. * *

E.g.: {@code Code-Review+1, CodeReview+2} * * @param sortedApprovalsForSameLabel the approvals that should be formatted as a comma-separated * list of label votes, must be sorted * @return the given approvals as a comma-separated list of label votes */ private String formatApprovalsAsLabelVotesList( Collection sortedApprovalsForSameLabel) { return sortedApprovalsForSameLabel.stream() .map(psa -> LabelVote.create(psa.label(), psa.value())) .distinct() .map(LabelVote::format) .collect(joining(", ")); } /** * Formats the given label vote with a comma-separated list of the given approvers. * *

E.g.: {@code Code-Review+1 by , } * * @param labelVote the label vote that should be formatted with a comma-separated list of the * given approver * @param sortedApprovers the approvers that should be formatted as a comma-separated list for the * given label vote * @return the given label vote with a comma-separated list of the given approvers */ private String formatLabelVoteWithApprovers( LabelVote labelVote, Collection sortedApprovers) { return new StringBuilder() .append(labelVote.format()) .append(" by ") .append( sortedApprovers.stream() .map(AccountTemplateUtil::getAccountTemplate) .collect(joining(", "))) .toString(); } /** * Gets {@link PatchSetApproval}s for a specified patch-set. The result includes copied votes but * does not include deleted labels. * * @param notes changenotes of the change. * @param psId patch-set id for the change and patch-set we want to get approvals. * @return all approvals for the specified patch-set, including copied votes, not including * deleted labels. */ public Iterable byPatchSet(ChangeNotes notes, PatchSet.Id psId) { List approvalsNotNormalized = notes.load().getApprovals().all().get(psId); return labelNormalizer.normalize(notes, approvalsNotNormalized).getNormalized(); } public Iterable byPatchSetUser( ChangeNotes notes, PatchSet.Id psId, Account.Id accountId) { return filterApprovals(byPatchSet(notes, psId), accountId); } public PatchSetApproval getSubmitter(ChangeNotes notes, PatchSet.Id c) { if (c == null) { return null; } try { // Submit approval is never copied. return getSubmitter(c, byChangeExcludingCopiedApprovals(notes).get(c)); } catch (StorageException e) { return null; } } public static PatchSetApproval getSubmitter(PatchSet.Id c, Iterable approvals) { if (c == null) { return null; } PatchSetApproval submitter = null; for (PatchSetApproval a : approvals) { if (a.patchSetId().equals(c) && a.value() > 0 && a.isLegacySubmit()) { if (submitter == null || a.granted().compareTo(submitter.granted()) > 0) { submitter = a; } } } return submitter; } public static String renderMessageWithApprovals( int patchSetId, Map n, Map c) { StringBuilder msgs = new StringBuilder("Uploaded patch set " + patchSetId); if (!n.isEmpty()) { boolean first = true; for (Map.Entry e : n.entrySet()) { if (c.containsKey(e.getKey()) && c.get(e.getKey()).value() == e.getValue()) { continue; } if (first) { msgs.append(":"); first = false; } msgs.append(" ").append(LabelVote.create(e.getKey(), e.getValue()).format()); } } return msgs.toString(); } }





© 2015 - 2024 Weber Informatics LLC | Privacy Policy