com.google.gerrit.server.change.ChangeJson Maven / Gradle / Ivy
// Copyright (C) 2012 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.collect.ImmutableMap.toImmutableMap;
import static com.google.gerrit.extensions.client.ListChangesOption.ALL_COMMITS;
import static com.google.gerrit.extensions.client.ListChangesOption.ALL_REVISIONS;
import static com.google.gerrit.extensions.client.ListChangesOption.CHANGE_ACTIONS;
import static com.google.gerrit.extensions.client.ListChangesOption.CHECK;
import static com.google.gerrit.extensions.client.ListChangesOption.COMMIT_FOOTERS;
import static com.google.gerrit.extensions.client.ListChangesOption.CURRENT_ACTIONS;
import static com.google.gerrit.extensions.client.ListChangesOption.CURRENT_COMMIT;
import static com.google.gerrit.extensions.client.ListChangesOption.CURRENT_REVISION;
import static com.google.gerrit.extensions.client.ListChangesOption.CUSTOM_KEYED_VALUES;
import static com.google.gerrit.extensions.client.ListChangesOption.DETAILED_ACCOUNTS;
import static com.google.gerrit.extensions.client.ListChangesOption.DETAILED_LABELS;
import static com.google.gerrit.extensions.client.ListChangesOption.LABELS;
import static com.google.gerrit.extensions.client.ListChangesOption.MESSAGES;
import static com.google.gerrit.extensions.client.ListChangesOption.REVIEWED;
import static com.google.gerrit.extensions.client.ListChangesOption.REVIEWER_UPDATES;
import static com.google.gerrit.extensions.client.ListChangesOption.SKIP_DIFFSTAT;
import static com.google.gerrit.extensions.client.ListChangesOption.STAR;
import static com.google.gerrit.extensions.client.ListChangesOption.SUBMITTABLE;
import static com.google.gerrit.extensions.client.ListChangesOption.SUBMIT_REQUIREMENTS;
import static com.google.gerrit.extensions.client.ListChangesOption.TRACKING_IDS;
import static com.google.gerrit.server.ChangeMessagesUtil.createChangeMessageInfo;
import static com.google.gerrit.server.util.AttentionSetUtil.additionsOnly;
import static com.google.gerrit.server.util.AttentionSetUtil.removalsOnly;
import static java.util.stream.Collectors.toList;
import com.google.common.base.Joiner;
import com.google.common.base.MoreObjects;
import com.google.common.base.Throwables;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableListMultimap;
import com.google.common.collect.ImmutableMap;
import com.google.common.collect.ImmutableSet;
import com.google.common.collect.ImmutableSortedMap;
import com.google.common.collect.ListMultimap;
import com.google.common.collect.Lists;
import com.google.common.collect.Maps;
import com.google.common.collect.Sets;
import com.google.common.flogger.FluentLogger;
import com.google.errorprone.annotations.CanIgnoreReturnValue;
import com.google.gerrit.common.Nullable;
import com.google.gerrit.entities.Account;
import com.google.gerrit.entities.Address;
import com.google.gerrit.entities.Change;
import com.google.gerrit.entities.ChangeMessage;
import com.google.gerrit.entities.LegacySubmitRequirement;
import com.google.gerrit.entities.PatchSet;
import com.google.gerrit.entities.PatchSetApproval;
import com.google.gerrit.entities.Project;
import com.google.gerrit.entities.SubmitRecord;
import com.google.gerrit.entities.SubmitRecord.Status;
import com.google.gerrit.entities.SubmitRequirementResult;
import com.google.gerrit.entities.SubmitTypeRecord;
import com.google.gerrit.exceptions.StorageException;
import com.google.gerrit.extensions.api.changes.FixInput;
import com.google.gerrit.extensions.client.ListChangesOption;
import com.google.gerrit.extensions.client.ReviewerState;
import com.google.gerrit.extensions.common.AccountInfo;
import com.google.gerrit.extensions.common.ApprovalInfo;
import com.google.gerrit.extensions.common.ChangeInfo;
import com.google.gerrit.extensions.common.ChangeMessageInfo;
import com.google.gerrit.extensions.common.LabelInfo;
import com.google.gerrit.extensions.common.LegacySubmitRequirementInfo;
import com.google.gerrit.extensions.common.PluginDefinedInfo;
import com.google.gerrit.extensions.common.ProblemInfo;
import com.google.gerrit.extensions.common.ReviewerUpdateInfo;
import com.google.gerrit.extensions.common.RevisionInfo;
import com.google.gerrit.extensions.common.SubmitRecordInfo;
import com.google.gerrit.extensions.common.SubmitRequirementResultInfo;
import com.google.gerrit.extensions.common.TrackingIdInfo;
import com.google.gerrit.extensions.restapi.Url;
import com.google.gerrit.index.query.QueryResult;
import com.google.gerrit.metrics.Description;
import com.google.gerrit.metrics.Description.Units;
import com.google.gerrit.metrics.MetricMaker;
import com.google.gerrit.metrics.Timer0;
import com.google.gerrit.server.ChangeMessagesUtil;
import com.google.gerrit.server.CurrentUser;
import com.google.gerrit.server.GpgException;
import com.google.gerrit.server.ReviewerByEmailSet;
import com.google.gerrit.server.ReviewerSet;
import com.google.gerrit.server.ReviewerStatusUpdate;
import com.google.gerrit.server.StarredChangesReader;
import com.google.gerrit.server.account.AccountInfoComparator;
import com.google.gerrit.server.account.AccountLoader;
import com.google.gerrit.server.cancellation.RequestCancelledException;
import com.google.gerrit.server.config.AllUsersName;
import com.google.gerrit.server.config.GerritServerConfig;
import com.google.gerrit.server.config.TrackingFooters;
import com.google.gerrit.server.git.GitRepositoryManager;
import com.google.gerrit.server.index.change.ChangeField;
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.ChangeNotes;
import com.google.gerrit.server.notedb.ReviewerStateInternal;
import com.google.gerrit.server.patch.PatchListNotAvailableException;
import com.google.gerrit.server.permissions.ChangePermission;
import com.google.gerrit.server.permissions.PermissionBackend;
import com.google.gerrit.server.permissions.PermissionBackendException;
import com.google.gerrit.server.project.RemoveReviewerControl;
import com.google.gerrit.server.project.SubmitRuleOptions;
import com.google.gerrit.server.query.change.ChangeData;
import com.google.gerrit.server.query.change.ChangeData.ChangedLines;
import com.google.gerrit.server.util.AttentionSetUtil;
import com.google.inject.Inject;
import com.google.inject.Provider;
import com.google.inject.Singleton;
import com.google.inject.assistedinject.Assisted;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.stream.Collectors;
import org.eclipse.jgit.lib.Config;
import org.eclipse.jgit.lib.ObjectId;
import org.eclipse.jgit.lib.Repository;
/**
* Produces {@link ChangeInfo} (which is serialized to JSON afterwards) from {@link ChangeData}.
*
* This is intended to be used on request scope, but may be used for converting multiple {@link
* ChangeData} objects from different sources.
*/
public class ChangeJson {
private static final FluentLogger logger = FluentLogger.forEnclosingClass();
public static final SubmitRuleOptions SUBMIT_RULE_OPTIONS_LENIENT =
ChangeField.SUBMIT_RULE_OPTIONS_LENIENT.toBuilder().build();
public static final SubmitRuleOptions SUBMIT_RULE_OPTIONS_STRICT =
ChangeField.SUBMIT_RULE_OPTIONS_STRICT.toBuilder().build();
public static final ImmutableSet REQUIRE_LAZY_LOAD =
ImmutableSet.of(
ALL_COMMITS,
ALL_REVISIONS,
CHANGE_ACTIONS,
CHECK,
COMMIT_FOOTERS,
CURRENT_ACTIONS,
CURRENT_COMMIT,
MESSAGES);
@Singleton
public static class Factory {
private final AssistedFactory factory;
@Inject
Factory(AssistedFactory factory) {
this.factory = factory;
}
public ChangeJson noOptions() {
return create(ImmutableSet.of());
}
public ChangeJson create(Iterable options) {
return factory.create(options, Optional.empty());
}
public ChangeJson create(
Iterable options, PluginDefinedInfosFactory pluginDefinedInfosFactory) {
return factory.create(options, Optional.of(pluginDefinedInfosFactory));
}
public ChangeJson create(ListChangesOption first, ListChangesOption... rest) {
return create(Sets.immutableEnumSet(first, rest));
}
}
public interface AssistedFactory {
ChangeJson create(
Iterable options,
Optional pluginDefinedInfosFactory);
}
@Singleton
private static class Metrics {
private final Timer0 toChangeInfoLatency;
private final Timer0 toChangeInfosLatency;
private final Timer0 formatQueryResultsLatency;
@Inject
Metrics(MetricMaker metricMaker) {
toChangeInfoLatency =
metricMaker.newTimer(
"http/server/rest_api/change_json/to_change_info_latency",
new Description("Latency for toChangeInfo invocations in ChangeJson")
.setCumulative()
.setUnit(Units.MILLISECONDS));
toChangeInfosLatency =
metricMaker.newTimer(
"http/server/rest_api/change_json/to_change_infos_latency",
new Description("Latency for toChangeInfos invocations in ChangeJson")
.setCumulative()
.setUnit(Units.MILLISECONDS));
formatQueryResultsLatency =
metricMaker.newTimer(
"http/server/rest_api/change_json/format_query_results_latency",
new Description("Latency for formatQueryResults invocations in ChangeJson")
.setCumulative()
.setUnit(Units.MILLISECONDS));
}
}
private final GitRepositoryManager repoManager;
private final AllUsersName allUsers;
private final Provider userProvider;
private final PermissionBackend permissionBackend;
private final ChangeData.Factory changeDataFactory;
private final AccountLoader.Factory accountLoaderFactory;
private final ImmutableSet options;
private final ChangeMessagesUtil cmUtil;
private final StarredChangesReader starredChangesreader;
private final Provider checkerProvider;
private final ActionJson actionJson;
private final ChangeNotes.Factory notesFactory;
private final LabelsJson labelsJson;
private final RemoveReviewerControl removeReviewerControl;
private final TrackingFooters trackingFooters;
private final Metrics metrics;
private final RevisionJson revisionJson;
private final Optional pluginDefinedInfosFactory;
private final boolean includeMergeable;
private final boolean lazyLoad;
private final boolean cacheQueryResultsByChangeNum;
private AccountLoader accountLoader;
private FixInput fix;
@Inject
ChangeJson(
GitRepositoryManager repoManager,
AllUsersName allUsers,
Provider user,
PermissionBackend permissionBackend,
ChangeData.Factory cdf,
AccountLoader.Factory ailf,
ChangeMessagesUtil cmUtil,
StarredChangesReader starredChangesreader,
Provider checkerProvider,
ActionJson actionJson,
ChangeNotes.Factory notesFactory,
LabelsJson labelsJson,
RemoveReviewerControl removeReviewerControl,
TrackingFooters trackingFooters,
Metrics metrics,
RevisionJson.Factory revisionJsonFactory,
@GerritServerConfig Config cfg,
@Assisted Iterable options,
@Assisted Optional pluginDefinedInfosFactory) {
this.repoManager = repoManager;
this.allUsers = allUsers;
this.userProvider = user;
this.changeDataFactory = cdf;
this.permissionBackend = permissionBackend;
this.accountLoaderFactory = ailf;
this.cmUtil = cmUtil;
this.starredChangesreader = starredChangesreader;
this.checkerProvider = checkerProvider;
this.actionJson = actionJson;
this.notesFactory = notesFactory;
this.labelsJson = labelsJson;
this.removeReviewerControl = removeReviewerControl;
this.trackingFooters = trackingFooters;
this.metrics = metrics;
this.revisionJson = revisionJsonFactory.create(options);
this.options = Sets.immutableEnumSet(options);
this.includeMergeable = MergeabilityComputationBehavior.fromConfig(cfg).includeInApi();
this.lazyLoad = containsAnyOf(this.options, REQUIRE_LAZY_LOAD);
this.pluginDefinedInfosFactory = pluginDefinedInfosFactory;
this.cacheQueryResultsByChangeNum =
cfg.getBoolean("index", "cacheQueryResultsByChangeNum", true);
logger.atFine().log("options = %s", options);
}
@CanIgnoreReturnValue
public ChangeJson fix(FixInput fix) {
this.fix = fix;
return this;
}
public ChangeInfo format(ChangeResource rsrc) {
return format(changeDataFactory.create(rsrc.getNotes()));
}
public ChangeInfo format(Change change) {
return format(changeDataFactory.create(change));
}
public ChangeInfo format(Change change, @Nullable ObjectId metaRevId) {
ChangeNotes notes = notesFactory.createChecked(change.getProject(), change.getId(), metaRevId);
return format(changeDataFactory.create(notes));
}
public ChangeInfo format(ChangeData cd) {
return format(cd, Optional.empty(), true, getPluginInfos(cd));
}
public ChangeInfo format(RevisionResource rsrc) {
ChangeData cd = changeDataFactory.create(rsrc.getNotes());
return format(cd, Optional.of(rsrc.getPatchSet().id()), true, getPluginInfos(cd));
}
public List> format(List> in)
throws PermissionBackendException {
try (Timer0.Context ignored = metrics.formatQueryResultsLatency.start()) {
accountLoader = accountLoaderFactory.create(has(DETAILED_ACCOUNTS));
List> res = new ArrayList<>(in.size());
Map cache = Maps.newHashMapWithExpectedSize(in.size());
ImmutableListMultimap pluginInfosByChange =
getPluginInfos(in.stream().flatMap(e -> e.entities().stream()).collect(toList()));
for (QueryResult r : in) {
List infos = toChangeInfos(r.entities(), cache, pluginInfosByChange);
if (!infos.isEmpty() && r.more()) {
infos.get(infos.size() - 1)._moreChanges = true;
}
res.add(infos);
}
accountLoader.fill();
return res;
}
}
public List format(Collection in) throws PermissionBackendException {
accountLoader = accountLoaderFactory.create(has(DETAILED_ACCOUNTS));
ensureLoaded(in);
List out = new ArrayList<>(in.size());
ImmutableListMultimap pluginInfosByChange = getPluginInfos(in);
for (ChangeData cd : in) {
out.add(format(cd, Optional.empty(), false, pluginInfosByChange.get(cd.getId())));
}
accountLoader.fill();
return out;
}
public ChangeInfo format(Project.NameKey project, Change.Id id) {
return format(project, id, null);
}
public ChangeInfo format(Project.NameKey project, Change.Id id, @Nullable ObjectId metaRevId) {
ChangeNotes notes;
try {
notes = notesFactory.createChecked(project, id, metaRevId);
} catch (StorageException e) {
if (!has(CHECK)) {
throw e;
}
return checkOnly(changeDataFactory.create(project, id));
}
ChangeData cd = changeDataFactory.create(notes);
return format(cd, Optional.empty(), true, getPluginInfos(cd));
}
private static List requirementsFor(ChangeData cd) {
try (TraceTimer timer =
TraceContext.newTimer(
"Get requirements", Metadata.builder().changeId(cd.change().getId().get()).build())) {
List reqInfos = new ArrayList<>();
for (SubmitRecord submitRecord : cd.submitRecords(SUBMIT_RULE_OPTIONS_STRICT)) {
if (submitRecord.requirements == null) {
continue;
}
for (LegacySubmitRequirement requirement : submitRecord.requirements) {
reqInfos.add(requirementToInfo(requirement, submitRecord.status));
}
}
return reqInfos;
}
}
private List submitRecordsFor(ChangeData cd) {
try (TraceTimer timer =
TraceContext.newTimer(
"Get submit records", Metadata.builder().changeId(cd.change().getId().get()).build())) {
List submitRecordInfos = new ArrayList<>();
for (SubmitRecord record : cd.submitRecords(SUBMIT_RULE_OPTIONS_STRICT)) {
submitRecordInfos.add(submitRecordToInfo(record));
}
return submitRecordInfos;
}
}
private List submitRequirementsFor(ChangeData cd) {
try (TraceTimer timer =
TraceContext.newTimer(
"Get submit requirements",
Metadata.builder().changeId(cd.change().getId().get()).build())) {
List reqInfos = new ArrayList<>();
cd.submitRequirementsIncludingLegacy().entrySet().stream()
.filter(entry -> !entry.getValue().isHidden())
.forEach(
entry ->
reqInfos.add(SubmitRequirementsJson.toInfo(entry.getKey(), entry.getValue())));
return reqInfos;
}
}
private static LegacySubmitRequirementInfo requirementToInfo(
LegacySubmitRequirement req, Status status) {
return new LegacySubmitRequirementInfo(status.name(), req.fallbackText(), req.type());
}
private SubmitRecordInfo submitRecordToInfo(SubmitRecord record) {
SubmitRecordInfo info = new SubmitRecordInfo();
if (record.status != null) {
info.status = SubmitRecordInfo.Status.valueOf(record.status.name());
}
info.ruleName = record.ruleName;
info.errorMessage = record.errorMessage;
if (record.labels != null) {
info.labels = new ArrayList<>();
for (SubmitRecord.Label label : record.labels) {
SubmitRecordInfo.Label labelInfo = new SubmitRecordInfo.Label();
labelInfo.label = label.label;
if (label.status != null) {
labelInfo.status = SubmitRecordInfo.Label.Status.valueOf(label.status.name());
}
labelInfo.appliedBy = accountLoader.get(label.appliedBy);
info.labels.add(labelInfo);
}
}
if (record.requirements != null) {
info.requirements = new ArrayList<>();
for (LegacySubmitRequirement requirement : record.requirements) {
info.requirements.add(requirementToInfo(requirement, record.status));
}
}
return info;
}
private static void finish(ChangeInfo info) {
info.tripletId =
Joiner.on('~')
.join(Url.encode(info.project), Url.encode(info.branch), Url.encode(info.changeId));
info.id =
Joiner.on('~').join(Url.encode(info.project), Url.encode(String.valueOf(info._number)));
}
private static boolean containsAnyOf(
ImmutableSet set, ImmutableSet toFind) {
return !Sets.intersection(toFind, set).isEmpty();
}
private ChangeInfo format(
ChangeData cd,
Optional limitToPsId,
boolean fillAccountLoader,
List pluginInfosForChange) {
try {
if (fillAccountLoader) {
accountLoader = accountLoaderFactory.create(has(DETAILED_ACCOUNTS));
ChangeInfo res = toChangeInfo(cd, limitToPsId, pluginInfosForChange);
accountLoader.fill();
return res;
}
return toChangeInfo(cd, limitToPsId, pluginInfosForChange);
} catch (PatchListNotAvailableException
| GpgException
| IOException
| PermissionBackendException
| RuntimeException e) {
if (!has(CHECK)) {
Throwables.throwIfInstanceOf(e, StorageException.class);
throw new StorageException(e);
}
return checkOnly(cd);
}
}
private void ensureLoaded(Collection all) {
if (lazyLoad) {
try (TraceTimer timer =
TraceContext.newTimer(
"Load change data for lazyLoad options",
Metadata.builder().resourceCount(all.size()).build())) {
for (ChangeData cd : all) {
// Mark all ChangeDatas as coming from the index, but allow backfilling data from NoteDb
cd.setStorageConstraint(ChangeData.StorageConstraint.INDEX_PRIMARY_NOTEDB_SECONDARY);
}
ChangeData.ensureChangeLoaded(all);
if (has(ALL_REVISIONS)) {
ChangeData.ensureAllPatchSetsLoaded(all);
} else if (has(CURRENT_REVISION) || has(MESSAGES)) {
ChangeData.ensureCurrentPatchSetLoaded(all);
}
if (has(REVIEWED) && userProvider.get().isIdentifiedUser()) {
ChangeData.ensureReviewedByLoadedForOpenChanges(all);
}
if (has(STAR) && userProvider.get().isIdentifiedUser()) {
ChangeData.ensureChangeServerId(all);
}
ChangeData.ensureCurrentApprovalsLoaded(all);
}
} else {
for (ChangeData cd : all) {
// Mark all ChangeDatas as coming from the index. Disallow using NoteDb
cd.setStorageConstraint(ChangeData.StorageConstraint.INDEX_ONLY);
}
}
}
private boolean has(ListChangesOption option) {
return options.contains(option);
}
private List toChangeInfos(
List changes,
Map cache,
ImmutableListMultimap pluginInfosByChange) {
try (Timer0.Context ignored = metrics.toChangeInfosLatency.start()) {
List changeInfos = new ArrayList<>(changes.size());
for (int i = 0; i < changes.size(); i++) {
// We can only cache and re-use an entity if it's not the last in the list. The last entity
// may later get _moreChanges set. If it was cached or re-used, that setting would propagate
// to the original entity yielding wrong results.
// This problem has two sides where 'last in the list' has to be respected:
// (1) Caching
// (2) Reusing
boolean isCacheable = cacheQueryResultsByChangeNum && (i != changes.size() - 1);
ChangeData cd = changes.get(i);
if (cd.hasFailedParsingFromIndex()) {
Optional faultyChangeInfo = createFaultyChangeInfo(cd);
if (faultyChangeInfo.isPresent()) {
changeInfos.add(faultyChangeInfo.get());
}
continue;
}
Change.Id cdUniqueId = cd.virtualId();
ChangeInfo info = cache.get(cdUniqueId);
if (info != null && isCacheable) {
changeInfos.add(info);
continue;
}
// Compute and cache if possible
try {
ensureLoaded(Collections.singleton(cd));
info = format(cd, Optional.empty(), false, pluginInfosByChange.get(cd.getId()));
changeInfos.add(info);
if (isCacheable) {
cache.put(cdUniqueId, info);
}
} catch (RuntimeException e) {
Optional requestCancelledException =
RequestCancelledException.getFromCausalChain(e);
if (requestCancelledException.isPresent()) {
throw e;
}
logger.atWarning().withCause(e).log(
"Omitting corrupt change %s from results", cd.getId());
}
}
if (has(STAR) && userProvider.get().isIdentifiedUser()) {
populateStarField(changeInfos);
}
return changeInfos;
}
}
private ChangeInfo checkOnly(ChangeData cd) {
ChangeNotes notes;
try {
notes = cd.notes();
} catch (StorageException e) {
String msg = "Error loading change";
logger.atWarning().withCause(e).log(msg + " %s", cd.getId());
ChangeInfo info = new ChangeInfo();
info._number = cd.getId().get();
ProblemInfo p = new ProblemInfo();
p.message = msg;
info.problems = Lists.newArrayList(p);
return info;
}
ConsistencyChecker.Result result = checkerProvider.get().check(notes, fix);
ChangeInfo info = new ChangeInfo();
Change c = result.change();
if (c != null) {
info.project = c.getProject().get();
info.branch = c.getDest().shortName();
info.topic = c.getTopic();
info.changeId = c.getKey().get();
info.subject = c.getSubject();
info.status = c.getStatus().asChangeStatus();
info.owner = new AccountInfo(c.getOwner().get());
info.setCreated(c.getCreatedOn());
info.setUpdated(c.getLastUpdatedOn());
info._number = c.getId().get();
info.problems = result.problems();
info.isPrivate = c.isPrivate() ? true : null;
info.workInProgress = c.isWorkInProgress() ? true : null;
info.hasReviewStarted = c.hasReviewStarted();
finish(info);
} else {
info._number = result.id().get();
info.problems = result.problems();
}
return info;
}
private ChangeInfo toChangeInfo(
ChangeData cd,
Optional limitToPsId,
List pluginInfosForChange)
throws PatchListNotAvailableException, GpgException, PermissionBackendException, IOException {
try (Timer0.Context ignored = metrics.toChangeInfoLatency.start()) {
return toChangeInfoImpl(cd, limitToPsId, pluginInfosForChange);
}
}
private ChangeInfo toChangeInfoImpl(
ChangeData cd, Optional limitToPsId, List pluginInfos)
throws PatchListNotAvailableException, GpgException, PermissionBackendException, IOException {
ChangeInfo out = new ChangeInfo();
CurrentUser user = userProvider.get();
if (has(CHECK)) {
out.problems = checkerProvider.get().check(cd.notes(), fix).problems();
// If any problems were fixed, the ChangeData needs to be reloaded.
if (out.problems.stream().anyMatch(p -> p.status == ProblemInfo.Status.FIXED)) {
try (TraceTimer timer =
TraceContext.newTimer(
"Reload change data after fixing a problem",
Metadata.builder().changeId(cd.change().getChangeId()).build())) {
cd = changeDataFactory.create(cd.project(), cd.getId());
}
}
}
Change in = cd.change();
out.project = in.getProject().get();
out.branch = in.getDest().shortName();
out.currentRevisionNumber = in.currentPatchSetId().get();
out.topic = in.getTopic();
if (!cd.attentionSet().isEmpty()) {
out.removedFromAttentionSet =
removalsOnly(cd.attentionSet()).stream()
.collect(
toImmutableMap(
a -> a.account().get(),
a -> AttentionSetUtil.createAttentionSetInfo(a, accountLoader)));
out.attentionSet =
// This filtering should match GetAttentionSet.
additionsOnly(cd.attentionSet()).stream()
.collect(
toImmutableMap(
a -> a.account().get(),
a -> AttentionSetUtil.createAttentionSetInfo(a, accountLoader)));
}
if (has(CUSTOM_KEYED_VALUES)) {
out.customKeyedValues = cd.customKeyedValues();
}
out.hashtags = cd.hashtags();
out.changeId = in.getKey().get();
if (in.isNew()) {
SubmitTypeRecord str = cd.submitTypeRecord();
if (str.isOk()) {
out.submitType = str.type;
}
if (includeMergeable) {
out.mergeable = cd.isMergeable();
}
if (has(SUBMITTABLE)) {
out.submittable = submittable(cd);
}
}
if (!has(SKIP_DIFFSTAT)) {
Optional changedLines = cd.changedLines();
if (changedLines.isPresent()) {
out.insertions = changedLines.get().insertions;
out.deletions = changedLines.get().deletions;
}
}
out.isPrivate = in.isPrivate() ? true : null;
out.workInProgress = in.isWorkInProgress() ? true : null;
out.hasReviewStarted = in.hasReviewStarted();
out.subject = in.getSubject();
out.status = in.getStatus().asChangeStatus();
out.owner = accountLoader.get(in.getOwner());
out.setCreated(in.getCreatedOn());
out.setUpdated(in.getLastUpdatedOn());
out._number = in.getId().get();
try (TraceTimer timer =
TraceContext.newTimer(
"Count comments", Metadata.builder().changeId(cd.change().getId().get()).build())) {
out.totalCommentCount = cd.totalCommentCount();
out.unresolvedCommentCount = cd.unresolvedCommentCount();
}
getMetaState(cd).ifPresent(id -> out.metaRevId = id.getName());
out.reviewed = isReviewedByCurrentUser(cd, user);
out.starred = isStarredByCurrentUser(cd, user);
out.labels = labelsJson.labelsFor(accountLoader, cd, has(LABELS), has(DETAILED_LABELS));
out.requirements = requirementsFor(cd);
out.submitRecords = submitRecordsFor(cd);
if (has(SUBMIT_REQUIREMENTS)) {
out.submitRequirements = submitRequirementsFor(cd);
}
if (out.labels != null && has(DETAILED_LABELS)) {
// If limited to specific patch sets but not the current patch set, don't
// list permitted labels, since users can't vote on those patch sets.
if (user.isIdentifiedUser()
&& (!limitToPsId.isPresent() || limitToPsId.get().equals(in.currentPatchSetId()))) {
out.permittedLabels =
!cd.change().isAbandoned()
? labelsJson.permittedLabels(user.getAccountId(), cd)
: ImmutableMap.of();
out.removableLabels = labelsJson.removableLabels(accountLoader, user, cd);
}
}
if (has(LABELS) || has(DETAILED_LABELS)) {
out.reviewers = reviewerMap(cd.reviewers(), cd.reviewersByEmail(), false);
out.pendingReviewers = reviewerMap(cd.pendingReviewers(), cd.pendingReviewersByEmail(), true);
out.removableReviewers = removableReviewers(cd, out);
}
setSubmitter(cd, out);
if (!pluginInfos.isEmpty()) {
out.plugins = pluginInfos;
}
out.revertOf = cd.change().getRevertOf() != null ? cd.change().getRevertOf().get() : null;
out.submissionId = cd.change().getSubmissionId();
out.cherryPickOfChange =
cd.change().getCherryPickOf() != null
? cd.change().getCherryPickOf().changeId().get()
: null;
out.cherryPickOfPatchSet =
cd.change().getCherryPickOf() != null ? cd.change().getCherryPickOf().get() : null;
if (has(REVIEWER_UPDATES)) {
out.reviewerUpdates = reviewerUpdates(cd);
}
boolean needMessages = has(MESSAGES);
boolean needRevisions = has(ALL_REVISIONS) || has(CURRENT_REVISION) || limitToPsId.isPresent();
ImmutableMap src;
if (needMessages || needRevisions) {
src = loadPatchSets(cd, limitToPsId);
} else {
src = null;
}
if (needMessages) {
out.messages = messages(cd);
}
finish(out);
// This block must come after the ChangeInfo is mostly populated, since
// it will be passed to ActionVisitors as-is.
if (needRevisions) {
out.revisions = revisionJson.getRevisions(accountLoader, cd, src, limitToPsId, out);
for (Map.Entry entry : out.revisions.entrySet()) {
if (entry.getValue().isCurrent) {
out.currentRevision = entry.getKey();
break;
}
}
}
if (has(CURRENT_ACTIONS) || has(CHANGE_ACTIONS)) {
actionJson.addChangeActions(out, cd);
}
if (has(TRACKING_IDS)) {
try (TraceTimer timer =
TraceContext.newTimer(
"Get tracking IDs", Metadata.builder().changeId(cd.change().getId().get()).build())) {
ListMultimap set = trackingFooters.extract(cd.commitFooters());
out.trackingIds =
set.entries().stream()
.map(e -> new TrackingIdInfo(e.getKey(), e.getValue()))
.collect(toList());
}
}
out._virtualIdNumber = cd.virtualId().get();
return out;
}
private Map> reviewerMap(
ReviewerSet reviewers, ReviewerByEmailSet reviewersByEmail, boolean includeRemoved) {
try (TraceTimer timer = TraceContext.newTimer("Get reviewer map", Metadata.empty())) {
Map> reviewerMap = new HashMap<>();
for (ReviewerStateInternal state : ReviewerStateInternal.values()) {
if (!includeRemoved && state == ReviewerStateInternal.REMOVED) {
continue;
}
List reviewersByState = toAccountInfo(reviewers.byState(state));
reviewersByState.addAll(toAccountInfoByEmail(reviewersByEmail.byState(state)));
if (!reviewersByState.isEmpty()) {
reviewerMap.put(state.asReviewerState(), reviewersByState);
}
}
return reviewerMap;
}
}
private List reviewerUpdates(ChangeData cd) {
try (TraceTimer timer =
TraceContext.newTimer(
"Get reviewer updates",
Metadata.builder().changeId(cd.change().getId().get()).build())) {
List reviewerUpdates = cd.reviewerUpdates();
List result = new ArrayList<>(reviewerUpdates.size());
for (ReviewerStatusUpdate c : reviewerUpdates) {
if (c.reviewer().isPresent()) {
result.add(
new ReviewerUpdateInfo(
c.date(),
accountLoader.get(c.updatedBy()),
accountLoader.get(c.reviewer().get()),
c.state().asReviewerState()));
}
if (c.reviewerByEmail().isPresent()) {
result.add(
new ReviewerUpdateInfo(
c.date(),
accountLoader.get(c.updatedBy()),
toAccountInfoByEmail(c.reviewerByEmail().get()),
c.state().asReviewerState()));
}
}
return result;
}
}
private boolean submittable(ChangeData cd) {
try (TraceTimer timer =
TraceContext.newTimer(
"Compute submittability",
Metadata.builder().changeId(cd.change().getId().get()).build())) {
return cd.submitRequirementsIncludingLegacy().values().stream()
.allMatch(SubmitRequirementResult::fulfilled);
}
}
private Optional getMetaState(ChangeData cd) {
try (TraceTimer timer =
TraceContext.newTimer(
"Get change meta ref",
Metadata.builder().changeId(cd.change().getId().get()).build())) {
return cd.metaRevision();
}
}
private Boolean isReviewedByCurrentUser(ChangeData cd, CurrentUser user) {
try (TraceTimer timer =
TraceContext.newTimer(
"Get reviewed by", Metadata.builder().changeId(cd.change().getId().get()).build())) {
return toBoolean(
cd.change().isNew()
&& has(REVIEWED)
&& user.isIdentifiedUser()
&& cd.isReviewedBy(user.getAccountId()));
}
}
private Boolean isStarredByCurrentUser(ChangeData cd, CurrentUser user) {
try (TraceTimer timer =
TraceContext.newTimer(
"Get starred by", Metadata.builder().changeId(cd.change().getId().get()).build())) {
return toBoolean(user.isIdentifiedUser() && cd.isStarred(user.getAccountId()));
}
}
private void setSubmitter(ChangeData cd, ChangeInfo out) {
try (TraceTimer timer =
TraceContext.newTimer(
"Set submitter", Metadata.builder().changeId(cd.change().getId().get()).build())) {
Optional s = cd.getSubmitApproval();
if (!s.isPresent()) {
return;
}
out.setSubmitted(s.get().granted(), accountLoader.get(s.get().accountId()));
}
}
private ImmutableList messages(ChangeData cd) {
try (TraceTimer timer =
TraceContext.newTimer(
"Get messages", Metadata.builder().changeId(cd.change().getId().get()).build())) {
List messages = cmUtil.byChange(cd.notes());
if (messages.isEmpty()) {
return ImmutableList.of();
}
List result = Lists.newArrayListWithCapacity(messages.size());
for (ChangeMessage message : messages) {
result.add(createChangeMessageInfo(message, accountLoader));
}
return ImmutableList.copyOf(result);
}
}
private List removableReviewers(ChangeData cd, ChangeInfo out)
throws PermissionBackendException {
try (TraceTimer timer =
TraceContext.newTimer(
"Get removable reviewers",
Metadata.builder().changeId(cd.change().getId().get()).build())) {
// Although this is called removableReviewers, this method also determines
// which CCs are removable.
//
// For reviewers, we need to look at each approval, because the reviewer
// should only be considered removable if *all* of their approvals can be
// removed. First, add all reviewers with *any* removable approval to the
// "removable" set. Along the way, if we encounter a non-removable approval,
// add the reviewer to the "fixed" set. Before we return, remove all members
// of "fixed" from "removable", because not all of their approvals can be
// removed.
Collection labels = out.labels.values();
Set fixed = Sets.newHashSetWithExpectedSize(labels.size());
Set removable = new HashSet<>();
// Add all reviewers, which will later be removed if they are in the "fixed" set.
removable.addAll(
out.reviewers.getOrDefault(ReviewerState.REVIEWER, Collections.emptySet()).stream()
.filter(a -> a._accountId != null)
.map(a -> Account.id(a._accountId))
.collect(Collectors.toSet()));
// Check if the user has the permission to remove a reviewer. This means we can bypass the
// testRemoveReviewer check for a specific reviewer in the loop saving potentially many
// permission checks.
boolean canRemoveAnyReviewer =
permissionBackend
.user(userProvider.get())
.change(cd)
.test(ChangePermission.REMOVE_REVIEWER);
for (LabelInfo label : labels) {
if (label.all == null) {
continue;
}
for (ApprovalInfo ai : label.all) {
Account.Id id = Account.id(ai._accountId);
if (!canRemoveAnyReviewer
&& !removeReviewerControl.testRemoveReviewer(
cd, userProvider.get(), id, MoreObjects.firstNonNull(ai.value, 0))) {
fixed.add(id);
}
}
}
// CCs are simpler than reviewers. They are removable if the ChangeControl
// would permit a non-negative approval by that account to be removed, in
// which case add them to removable. We don't need to add unremovable CCs to
// "fixed" because we only visit each CC once here.
Collection ccs = out.reviewers.get(ReviewerState.CC);
if (ccs != null) {
for (AccountInfo ai : ccs) {
if (ai._accountId != null) {
Account.Id id = Account.id(ai._accountId);
if (canRemoveAnyReviewer
|| removeReviewerControl.testRemoveReviewer(cd, userProvider.get(), id, 0)) {
removable.add(id);
}
}
}
}
// Subtract any reviewers with non-removable approvals from the "removable"
// set. This also subtracts any CCs that for some reason also hold
// unremovable approvals.
removable.removeAll(fixed);
List result = Lists.newArrayListWithCapacity(removable.size());
for (Account.Id id : removable) {
result.add(accountLoader.get(id));
}
// Reviewers added by email are always removable
for (Collection infos : out.reviewers.values()) {
for (AccountInfo info : infos) {
if (info._accountId == null) {
result.add(info);
}
}
}
return result;
}
}
private List toAccountInfo(Collection accounts) {
return accounts.stream()
.map(accountLoader::get)
.sorted(AccountInfoComparator.ORDER_NULLS_FIRST)
.collect(toList());
}
private AccountInfo toAccountInfoByEmail(Address address) {
return new AccountInfo(address.name(), address.email());
}
private List toAccountInfoByEmail(Collection addresses) {
return addresses.stream()
.map(this::toAccountInfoByEmail)
.sorted(AccountInfoComparator.ORDER_NULLS_FIRST)
.collect(toList());
}
private ImmutableMap loadPatchSets(
ChangeData cd, Optional limitToPsId) {
try (TraceTimer timer =
TraceContext.newTimer(
"Load patch sets", Metadata.builder().changeId(cd.change().getId().get()).build())) {
Collection src;
if (has(ALL_REVISIONS) || has(MESSAGES)) {
src = cd.patchSets();
} else {
PatchSet ps;
if (limitToPsId.isPresent()) {
ps = cd.patchSet(limitToPsId.get());
if (ps == null) {
throw new StorageException("missing patch set " + limitToPsId.get());
}
} else {
ps = cd.currentPatchSet();
if (ps == null) {
throw new StorageException("missing current patch set for change " + cd.getId());
}
}
src = Collections.singletonList(ps);
}
// Sort by patch set ID in increasing order to have a stable output.
ImmutableSortedMap.Builder map = ImmutableSortedMap.naturalOrder();
for (PatchSet patchSet : src) {
map.put(patchSet.id(), patchSet);
}
return map.build();
}
}
/** Populate the 'starred' field. */
private void populateStarField(List changeInfos) {
// We populate the 'starred' field for all change infos together so that we open the All-Users
// repository only once
try (Repository allUsersRepo = repoManager.openRepository(allUsers)) {
List changeIds =
changeInfos.stream().map(c -> Change.id(c._virtualIdNumber)).collect(Collectors.toList());
Set starredChanges =
starredChangesreader.areStarred(
allUsersRepo, changeIds, userProvider.get().asIdentifiedUser().getAccountId());
if (starredChanges.isEmpty()) {
return;
}
changeInfos.stream()
.forEach(c -> c.starred = starredChanges.contains(Change.id(c._virtualIdNumber)));
} catch (IOException e) {
logger.atWarning().withCause(e).log("Failed to open All-Users repo.");
}
}
private ImmutableList getPluginInfos(ChangeData cd) {
return getPluginInfos(Collections.singleton(cd)).get(cd.getId());
}
private ImmutableListMultimap getPluginInfos(
Collection cds) {
if (pluginDefinedInfosFactory.isPresent()) {
try (TraceTimer timer =
TraceContext.newTimer(
"Get plugin infos", Metadata.builder().resourceCount(cds.size()).build())) {
return pluginDefinedInfosFactory.get().createPluginDefinedInfos(cds);
}
}
return ImmutableListMultimap.of();
}
/**
* Create an empty {@link ChangeInfo} designating a faulty record if {@link
* ChangeData#hasFailedParsingFromIndex()} is true.
*
* Few fields are populated: project, branch, changeId, _number, subject, owner.
*/
private static Optional createFaultyChangeInfo(ChangeData cd) {
ChangeInfo info = new ChangeInfo();
Change c = cd.change();
if (c == null) {
return Optional.empty();
}
info.project = c.getProject().get();
info.branch = c.getDest().shortName();
info.changeId = c.getKey().get();
info._number = c.getId().get();
info.subject = "***ERROR***";
info.owner = new AccountInfo(c.getOwner().get());
return Optional.of(info);
}
@Nullable
private static Boolean toBoolean(boolean value) {
return value ? true : null;
}
}