LCOV - code coverage report
Current view: top level - server/restapi/change - ReviewersUtil.java (source / functions) Hit Total Coverage
Test: _coverage_report.dat Lines: 174 209 83.3 %
Date: 2022-11-19 15:00:39 Functions: 14 17 82.4 %

          Line data    Source code
       1             : // Copyright (C) 2016 The Android Open Source Project
       2             : //
       3             : // Licensed under the Apache License, Version 2.0 (the "License");
       4             : // you may not use this file except in compliance with the License.
       5             : // You may obtain a copy of the License at
       6             : //
       7             : // http://www.apache.org/licenses/LICENSE-2.0
       8             : //
       9             : // Unless required by applicable law or agreed to in writing, software
      10             : // distributed under the License is distributed on an "AS IS" BASIS,
      11             : // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
      12             : // See the License for the specific language governing permissions and
      13             : // limitations under the License.
      14             : 
      15             : package com.google.gerrit.server.restapi.change;
      16             : 
      17             : import static com.google.common.flogger.LazyArgs.lazy;
      18             : import static java.util.stream.Collectors.toList;
      19             : 
      20             : import com.google.common.base.Strings;
      21             : import com.google.common.collect.ImmutableList;
      22             : import com.google.common.collect.ImmutableSet;
      23             : import com.google.common.collect.Sets;
      24             : import com.google.common.flogger.FluentLogger;
      25             : import com.google.gerrit.common.Nullable;
      26             : import com.google.gerrit.entities.Account;
      27             : import com.google.gerrit.entities.GroupReference;
      28             : import com.google.gerrit.entities.Project;
      29             : import com.google.gerrit.exceptions.StorageException;
      30             : import com.google.gerrit.extensions.client.ReviewerState;
      31             : import com.google.gerrit.extensions.common.AccountVisibility;
      32             : import com.google.gerrit.extensions.common.GroupBaseInfo;
      33             : import com.google.gerrit.extensions.common.SuggestedReviewerInfo;
      34             : import com.google.gerrit.extensions.restapi.BadRequestException;
      35             : import com.google.gerrit.extensions.restapi.Url;
      36             : import com.google.gerrit.index.IndexConfig;
      37             : import com.google.gerrit.index.QueryOptions;
      38             : import com.google.gerrit.index.SchemaFieldDefs.SchemaField;
      39             : import com.google.gerrit.index.query.FieldBundle;
      40             : import com.google.gerrit.index.query.Predicate;
      41             : import com.google.gerrit.index.query.QueryParseException;
      42             : import com.google.gerrit.index.query.ResultSet;
      43             : import com.google.gerrit.index.query.TooManyTermsInQueryException;
      44             : import com.google.gerrit.metrics.Description;
      45             : import com.google.gerrit.metrics.Description.Units;
      46             : import com.google.gerrit.metrics.MetricMaker;
      47             : import com.google.gerrit.metrics.Timer0;
      48             : import com.google.gerrit.server.CurrentUser;
      49             : import com.google.gerrit.server.account.AccountControl;
      50             : import com.google.gerrit.server.account.AccountDirectory.FillOptions;
      51             : import com.google.gerrit.server.account.AccountLoader;
      52             : import com.google.gerrit.server.account.AccountState;
      53             : import com.google.gerrit.server.account.GroupBackend;
      54             : import com.google.gerrit.server.account.GroupMembers;
      55             : import com.google.gerrit.server.account.ServiceUserClassifier;
      56             : import com.google.gerrit.server.change.ReviewerModifier;
      57             : import com.google.gerrit.server.index.account.AccountField;
      58             : import com.google.gerrit.server.index.account.AccountIndexCollection;
      59             : import com.google.gerrit.server.index.account.AccountIndexRewriter;
      60             : import com.google.gerrit.server.notedb.ChangeNotes;
      61             : import com.google.gerrit.server.permissions.GlobalPermission;
      62             : import com.google.gerrit.server.permissions.PermissionBackendException;
      63             : import com.google.gerrit.server.project.NoSuchProjectException;
      64             : import com.google.gerrit.server.project.ProjectState;
      65             : import com.google.gerrit.server.query.account.AccountPredicates;
      66             : import com.google.gerrit.server.query.account.AccountQueryBuilder;
      67             : import com.google.inject.Inject;
      68             : import com.google.inject.Provider;
      69             : import com.google.inject.Singleton;
      70             : import java.io.IOException;
      71             : import java.util.ArrayList;
      72             : import java.util.Collections;
      73             : import java.util.EnumSet;
      74             : import java.util.List;
      75             : import java.util.Objects;
      76             : import java.util.Set;
      77             : import org.eclipse.jgit.errors.ConfigInvalidException;
      78             : 
      79             : public class ReviewersUtil {
      80          91 :   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
      81             : 
      82             :   @Singleton
      83             :   private static class Metrics {
      84             :     final Timer0 queryAccountsLatency;
      85             :     final Timer0 recommendAccountsLatency;
      86             :     final Timer0 loadAccountsLatency;
      87             :     final Timer0 queryGroupsLatency;
      88             :     final Timer0 filterVisibility;
      89             : 
      90             :     @Inject
      91          91 :     Metrics(MetricMaker metricMaker) {
      92          91 :       queryAccountsLatency =
      93          91 :           metricMaker.newTimer(
      94             :               "reviewer_suggestion/query_accounts",
      95             :               new Description("Latency for querying accounts for reviewer suggestion")
      96          91 :                   .setCumulative()
      97          91 :                   .setUnit(Units.MILLISECONDS));
      98          91 :       recommendAccountsLatency =
      99          91 :           metricMaker.newTimer(
     100             :               "reviewer_suggestion/recommend_accounts",
     101             :               new Description("Latency for recommending accounts for reviewer suggestion")
     102          91 :                   .setCumulative()
     103          91 :                   .setUnit(Units.MILLISECONDS));
     104          91 :       loadAccountsLatency =
     105          91 :           metricMaker.newTimer(
     106             :               "reviewer_suggestion/load_accounts",
     107             :               new Description("Latency for loading accounts for reviewer suggestion")
     108          91 :                   .setCumulative()
     109          91 :                   .setUnit(Units.MILLISECONDS));
     110          91 :       queryGroupsLatency =
     111          91 :           metricMaker.newTimer(
     112             :               "reviewer_suggestion/query_groups",
     113             :               new Description("Latency for querying groups for reviewer suggestion")
     114          91 :                   .setCumulative()
     115          91 :                   .setUnit(Units.MILLISECONDS));
     116          91 :       filterVisibility =
     117          91 :           metricMaker.newTimer(
     118             :               "reviewer_suggestion/filter_visibility",
     119             :               new Description("Latency for removing users that can't see the change")
     120          91 :                   .setCumulative()
     121          91 :                   .setUnit(Units.MILLISECONDS));
     122          91 :     }
     123             :   }
     124             : 
     125             :   private final AccountVisibility accountVisibility;
     126             :   private final AccountLoader.Factory accountLoaderFactory;
     127             :   private final AccountQueryBuilder accountQueryBuilder;
     128             :   private final AccountIndexRewriter accountIndexRewriter;
     129             :   private final GroupBackend groupBackend;
     130             :   private final GroupMembers groupMembers;
     131             :   private final ReviewerRecommender reviewerRecommender;
     132             :   private final Metrics metrics;
     133             :   private final AccountIndexCollection accountIndexes;
     134             :   private final IndexConfig indexConfig;
     135             :   private final AccountControl.Factory accountControlFactory;
     136             :   private final Provider<CurrentUser> self;
     137             :   private final ServiceUserClassifier serviceUserClassifier;
     138             : 
     139             :   @Inject
     140             :   ReviewersUtil(
     141             :       AccountVisibility accountVisibility,
     142             :       AccountLoader.Factory accountLoaderFactory,
     143             :       AccountQueryBuilder accountQueryBuilder,
     144             :       AccountIndexRewriter accountIndexRewriter,
     145             :       GroupBackend groupBackend,
     146             :       GroupMembers groupMembers,
     147             :       ReviewerRecommender reviewerRecommender,
     148             :       Metrics metrics,
     149             :       AccountIndexCollection accountIndexes,
     150             :       IndexConfig indexConfig,
     151             :       AccountControl.Factory accountControlFactory,
     152             :       Provider<CurrentUser> self,
     153          91 :       ServiceUserClassifier serviceUserClassifier) {
     154          91 :     this.accountVisibility = accountVisibility;
     155          91 :     this.accountLoaderFactory = accountLoaderFactory;
     156          91 :     this.accountQueryBuilder = accountQueryBuilder;
     157          91 :     this.accountIndexRewriter = accountIndexRewriter;
     158          91 :     this.groupBackend = groupBackend;
     159          91 :     this.groupMembers = groupMembers;
     160          91 :     this.reviewerRecommender = reviewerRecommender;
     161          91 :     this.metrics = metrics;
     162          91 :     this.accountIndexes = accountIndexes;
     163          91 :     this.indexConfig = indexConfig;
     164          91 :     this.accountControlFactory = accountControlFactory;
     165          91 :     this.self = self;
     166          91 :     this.serviceUserClassifier = serviceUserClassifier;
     167          91 :   }
     168             : 
     169             :   public interface VisibilityControl {
     170             :     boolean isVisibleTo(Account.Id account);
     171             :   }
     172             : 
     173             :   public List<SuggestedReviewerInfo> suggestReviewers(
     174             :       ReviewerState reviewerState,
     175             :       @Nullable ChangeNotes changeNotes,
     176             :       SuggestReviewers suggestReviewers,
     177             :       ProjectState projectState,
     178             :       VisibilityControl visibilityControl,
     179             :       boolean excludeGroups)
     180             :       throws IOException, ConfigInvalidException, PermissionBackendException, BadRequestException {
     181           2 :     CurrentUser currentUser = self.get();
     182           2 :     if (changeNotes != null) {
     183           2 :       logger.atFine().log(
     184             :           "Suggesting reviewers for change %s to user %s.",
     185           2 :           changeNotes.getChangeId().get(), currentUser.getLoggableName());
     186             :     } else {
     187           0 :       logger.atFine().log(
     188             :           "Suggesting default reviewers for project %s to user %s.",
     189           0 :           projectState.getName(), currentUser.getLoggableName());
     190             :     }
     191             : 
     192           2 :     String query = suggestReviewers.getQuery();
     193           2 :     logger.atFine().log("Query: %s", query);
     194           2 :     int limit = suggestReviewers.getLimit();
     195             : 
     196           2 :     if (!suggestReviewers.getSuggestAccounts()) {
     197           1 :       logger.atFine().log("Reviewer suggestion is disabled.");
     198           1 :       return Collections.emptyList();
     199             :     }
     200           2 :     AccountControl accountControl = accountControlFactory.get();
     201             : 
     202           2 :     if (accountVisibility == AccountVisibility.NONE && !accountControl.canViewAll()) {
     203           1 :       logger.atFine().log(
     204             :           "Not suggesting reviewers: accountVisibility = %s and the user does not have %s capability",
     205             :           AccountVisibility.NONE, GlobalPermission.VIEW_ALL_ACCOUNTS);
     206           1 :       return Collections.emptyList();
     207             :     }
     208             : 
     209           2 :     List<Account.Id> candidateList = new ArrayList<>();
     210           2 :     if (!Strings.isNullOrEmpty(query)) {
     211           1 :       candidateList = suggestAccounts(suggestReviewers);
     212           1 :       logger.atFine().log("Candidate list: %s", candidateList);
     213             :     }
     214           2 :     List<Account.Id> sortedRecommendations =
     215           2 :         recommendAccounts(
     216             :             reviewerState, changeNotes, suggestReviewers, projectState, candidateList);
     217           2 :     logger.atFine().log("Sorted recommendations: %s", sortedRecommendations);
     218             : 
     219             :     // Filter accounts by visibility, skip service users and enforce limit
     220           2 :     List<Account.Id> filteredRecommendations = new ArrayList<>();
     221           2 :     try (Timer0.Context ctx = metrics.filterVisibility.start()) {
     222           2 :       for (Account.Id reviewer : sortedRecommendations) {
     223           1 :         if (filteredRecommendations.size() >= limit) {
     224           0 :           break;
     225             :         }
     226           1 :         if (suggestReviewers.isSkipServiceUsers()
     227           1 :             && serviceUserClassifier.isServiceUser(reviewer)) {
     228           1 :           continue;
     229             :         }
     230             :         // Check if change is visible to reviewer and if the current user can see reviewer
     231           1 :         if (visibilityControl.isVisibleTo(reviewer) && accountControl.canSee(reviewer)) {
     232           1 :           filteredRecommendations.add(reviewer);
     233             :         }
     234           1 :       }
     235             :     }
     236           2 :     logger.atFine().log("Filtered recommendations: %s", filteredRecommendations);
     237             : 
     238           2 :     List<SuggestedReviewerInfo> suggestedReviewers =
     239           2 :         suggestReviewers(
     240             :             suggestReviewers,
     241             :             projectState,
     242             :             visibilityControl,
     243             :             excludeGroups,
     244             :             filteredRecommendations);
     245           2 :     logger.atFine().log(
     246           2 :         "Suggested reviewers: %s", lazy(() -> formatSuggestedReviewers(suggestedReviewers)));
     247           2 :     return suggestedReviewers;
     248             :   }
     249             : 
     250             :   private static Account.Id fromIdField(FieldBundle f, boolean useLegacyNumericFields) {
     251           1 :     if (useLegacyNumericFields) {
     252           0 :       return Account.id(f.<Integer>getValue(AccountField.ID_FIELD_SPEC).intValue());
     253             :     }
     254           1 :     return Account.id(Integer.valueOf(f.<String>getValue(AccountField.ID_STR_FIELD_SPEC)));
     255             :   }
     256             : 
     257             :   private List<Account.Id> suggestAccounts(SuggestReviewers suggestReviewers)
     258             :       throws BadRequestException {
     259           1 :     try (Timer0.Context ctx = metrics.queryAccountsLatency.start()) {
     260             :       // For performance reasons we don't use AccountQueryProvider as it would always load the
     261             :       // complete account from the cache (or worse, from NoteDb) even though we only need the ID
     262             :       // which we can directly get from the returned results.
     263           1 :       Predicate<AccountState> pred =
     264           1 :           Predicate.and(
     265           1 :               AccountPredicates.isActive(),
     266           1 :               accountQueryBuilder.defaultQuery(suggestReviewers.getQuery()));
     267           1 :       logger.atFine().log("accounts index query: %s", pred);
     268           1 :       accountIndexRewriter.validateMaxTermsInQuery(pred);
     269           1 :       boolean useLegacyNumericFields =
     270           1 :           accountIndexes.getSearchIndex().getSchema().hasField(AccountField.ID_FIELD_SPEC);
     271             :       SchemaField<AccountState, ?> idField =
     272           1 :           useLegacyNumericFields ? AccountField.ID_FIELD_SPEC : AccountField.ID_STR_FIELD_SPEC;
     273           1 :       ResultSet<FieldBundle> result =
     274             :           accountIndexes
     275           1 :               .getSearchIndex()
     276           1 :               .getSource(
     277             :                   pred,
     278           1 :                   QueryOptions.create(
     279             :                       indexConfig,
     280             :                       0,
     281           1 :                       suggestReviewers.getLimit(),
     282           1 :                       ImmutableSet.of(idField.getName())))
     283           1 :               .readRaw();
     284           1 :       List<Account.Id> matches =
     285           1 :           result.toList().stream()
     286           1 :               .map(f -> fromIdField(f, useLegacyNumericFields))
     287           1 :               .collect(toList());
     288           1 :       logger.atFine().log("Matches: %s", matches);
     289           1 :       return matches;
     290           1 :     } catch (TooManyTermsInQueryException e) {
     291           1 :       throw new BadRequestException(e.getMessage());
     292           0 :     } catch (QueryParseException e) {
     293           0 :       logger.atWarning().withCause(e).log("Suggesting accounts failed, return empty result.");
     294           0 :       return ImmutableList.of();
     295           0 :     } catch (StorageException e) {
     296           0 :       if (e.getCause() instanceof TooManyTermsInQueryException) {
     297           0 :         throw new BadRequestException(e.getMessage());
     298             :       }
     299           0 :       if (e.getCause() instanceof QueryParseException) {
     300           0 :         return ImmutableList.of();
     301             :       }
     302           0 :       throw e;
     303             :     }
     304             :   }
     305             : 
     306             :   private List<SuggestedReviewerInfo> suggestReviewers(
     307             :       SuggestReviewers suggestReviewers,
     308             :       ProjectState projectState,
     309             :       VisibilityControl visibilityControl,
     310             :       boolean excludeGroups,
     311             :       List<Account.Id> filteredRecommendations)
     312             :       throws PermissionBackendException, IOException {
     313           2 :     List<SuggestedReviewerInfo> suggestedReviewers = loadAccounts(filteredRecommendations);
     314             : 
     315           2 :     int limit = suggestReviewers.getLimit();
     316           2 :     if (!excludeGroups
     317           2 :         && suggestedReviewers.size() < limit
     318           2 :         && !Strings.isNullOrEmpty(suggestReviewers.getQuery())) {
     319             :       // Add groups at the end as individual accounts are usually more
     320             :       // important.
     321           1 :       suggestedReviewers.addAll(
     322           1 :           suggestAccountGroups(
     323             :               suggestReviewers,
     324             :               projectState,
     325             :               visibilityControl,
     326           1 :               limit - suggestedReviewers.size()));
     327             :     }
     328             : 
     329           2 :     if (suggestedReviewers.size() > limit) {
     330           0 :       suggestedReviewers = suggestedReviewers.subList(0, limit);
     331           0 :       logger.atFine().log("Limited suggested reviewers to %d accounts.", limit);
     332             :     }
     333           2 :     return suggestedReviewers;
     334             :   }
     335             : 
     336             :   private List<Account.Id> recommendAccounts(
     337             :       ReviewerState reviewerState,
     338             :       @Nullable ChangeNotes changeNotes,
     339             :       SuggestReviewers suggestReviewers,
     340             :       ProjectState projectState,
     341             :       List<Account.Id> candidateList)
     342             :       throws IOException, ConfigInvalidException {
     343           2 :     try (Timer0.Context ctx = metrics.recommendAccountsLatency.start()) {
     344           2 :       return reviewerRecommender.suggestReviewers(
     345             :           reviewerState, changeNotes, suggestReviewers, projectState, candidateList);
     346             :     }
     347             :   }
     348             : 
     349             :   private List<SuggestedReviewerInfo> loadAccounts(List<Account.Id> accountIds)
     350             :       throws PermissionBackendException {
     351           2 :     Set<FillOptions> fillOptions =
     352           2 :         Sets.union(AccountLoader.DETAILED_OPTIONS, EnumSet.of(FillOptions.SECONDARY_EMAILS));
     353           2 :     AccountLoader accountLoader = accountLoaderFactory.create(fillOptions);
     354             : 
     355           2 :     try (Timer0.Context ctx = metrics.loadAccountsLatency.start()) {
     356           2 :       List<SuggestedReviewerInfo> reviewer =
     357           2 :           accountIds.stream()
     358           2 :               .map(accountLoader::get)
     359           2 :               .filter(Objects::nonNull)
     360           2 :               .map(
     361             :                   a -> {
     362           1 :                     SuggestedReviewerInfo info = new SuggestedReviewerInfo();
     363           1 :                     info.account = a;
     364           1 :                     info.count = 1;
     365           1 :                     return info;
     366             :                   })
     367           2 :               .collect(toList());
     368           2 :       accountLoader.fill();
     369           2 :       return reviewer;
     370             :     }
     371             :   }
     372             : 
     373             :   private List<SuggestedReviewerInfo> suggestAccountGroups(
     374             :       SuggestReviewers suggestReviewers,
     375             :       ProjectState projectState,
     376             :       VisibilityControl visibilityControl,
     377             :       int limit)
     378             :       throws IOException {
     379           1 :     try (Timer0.Context ctx = metrics.queryGroupsLatency.start()) {
     380           1 :       List<SuggestedReviewerInfo> groups = new ArrayList<>();
     381           1 :       for (GroupReference g : suggestAccountGroups(suggestReviewers, projectState)) {
     382           1 :         GroupAsReviewer result =
     383           1 :             suggestGroupAsReviewer(
     384           1 :                 suggestReviewers, projectState.getProject(), g, visibilityControl);
     385           1 :         if (result.allowed || result.allowedWithConfirmation) {
     386           1 :           GroupBaseInfo info = new GroupBaseInfo();
     387           1 :           info.id = Url.encode(g.getUUID().get());
     388           1 :           info.name = g.getName();
     389           1 :           SuggestedReviewerInfo suggestedReviewerInfo = new SuggestedReviewerInfo();
     390           1 :           suggestedReviewerInfo.group = info;
     391           1 :           suggestedReviewerInfo.count = result.size;
     392           1 :           if (result.allowedWithConfirmation) {
     393           1 :             suggestedReviewerInfo.confirm = true;
     394             :           }
     395           1 :           groups.add(suggestedReviewerInfo);
     396           1 :           if (groups.size() >= limit) {
     397           1 :             break;
     398             :           }
     399             :         }
     400           1 :       }
     401           1 :       return groups;
     402             :     }
     403             :   }
     404             : 
     405             :   private List<GroupReference> suggestAccountGroups(
     406             :       SuggestReviewers suggestReviewers, ProjectState projectState) {
     407           1 :     return groupBackend.suggest(suggestReviewers.getQuery(), projectState).stream()
     408           1 :         .limit(suggestReviewers.getLimit())
     409           1 :         .collect(toList());
     410             :   }
     411             : 
     412             :   private static class GroupAsReviewer {
     413             :     boolean allowed;
     414             :     boolean allowedWithConfirmation;
     415             :     int size;
     416             :   }
     417             : 
     418             :   private GroupAsReviewer suggestGroupAsReviewer(
     419             :       SuggestReviewers suggestReviewers,
     420             :       Project project,
     421             :       GroupReference group,
     422             :       VisibilityControl visibilityControl)
     423             :       throws IOException {
     424           1 :     GroupAsReviewer result = new GroupAsReviewer();
     425           1 :     int maxAllowed = suggestReviewers.getMaxAllowed();
     426           1 :     int maxAllowedWithoutConfirmation = suggestReviewers.getMaxAllowedWithoutConfirmation();
     427           1 :     logger.atFine().log("maxAllowedWithoutConfirmation: %s", maxAllowedWithoutConfirmation);
     428             : 
     429           1 :     if (!ReviewerModifier.isLegalReviewerGroup(group.getUUID())) {
     430           0 :       logger.atFine().log("Ignore group %s that is not legal as reviewer", group.getUUID());
     431           0 :       return result;
     432             :     }
     433             : 
     434             :     try {
     435           1 :       Set<Account> members = groupMembers.listAccounts(group.getUUID(), project.getNameKey());
     436             : 
     437           1 :       if (members.isEmpty()) {
     438           0 :         logger.atFine().log("Ignore group %s since it has no members", group.getUUID());
     439           0 :         return result;
     440             :       }
     441             : 
     442           1 :       result.size = members.size();
     443           1 :       if (maxAllowed > 0 && result.size > maxAllowed) {
     444           1 :         return result;
     445             :       }
     446             : 
     447           1 :       boolean needsConfirmation =
     448             :           maxAllowedWithoutConfirmation > 0 && result.size > maxAllowedWithoutConfirmation;
     449           1 :       if (needsConfirmation) {
     450           1 :         logger.atFine().log(
     451             :             "group %s needs confirmation to be added as reviewer, it has %d members",
     452           1 :             group.getUUID(), result.size);
     453             :       }
     454             : 
     455             :       // require that at least one member in the group can see the change
     456           1 :       for (Account account : members) {
     457           1 :         if (visibilityControl.isVisibleTo(account.id())) {
     458           1 :           if (needsConfirmation) {
     459           1 :             result.allowedWithConfirmation = true;
     460             :           } else {
     461           1 :             result.allowed = true;
     462             :           }
     463           1 :           logger.atFine().log("Suggest group %s", group.getUUID());
     464           1 :           return result;
     465             :         }
     466           0 :       }
     467           0 :       logger.atFine().log(
     468           0 :           "Ignore group %s since none of its members can see the change", group.getUUID());
     469           0 :     } catch (NoSuchProjectException e) {
     470           0 :       return result;
     471           0 :     }
     472             : 
     473           0 :     return result;
     474             :   }
     475             : 
     476             :   private static String formatSuggestedReviewers(List<SuggestedReviewerInfo> suggestedReviewers) {
     477           0 :     return suggestedReviewers.stream()
     478           0 :         .map(
     479             :             r -> {
     480           0 :               if (r.account != null) {
     481           0 :                 return "a/" + r.account._accountId;
     482           0 :               } else if (r.group != null) {
     483           0 :                 return "g/" + r.group.id;
     484             :               } else {
     485           0 :                 return "";
     486             :               }
     487             :             })
     488           0 :         .collect(toList())
     489           0 :         .toString();
     490             :   }
     491             : }

Generated by: LCOV version 1.16+git.20220603.dfeb750