blob: d4b837f0bca25a1dd46a67bd4b0fe0bf3ff097b5 [file] [log] [blame]
// 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}.
*
* <p>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<ListChangesOption> 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<ListChangesOption> options) {
return factory.create(options, Optional.empty());
}
public ChangeJson create(
Iterable<ListChangesOption> 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<ListChangesOption> options,
Optional<PluginDefinedInfosFactory> 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<CurrentUser> userProvider;
private final PermissionBackend permissionBackend;
private final ChangeData.Factory changeDataFactory;
private final AccountLoader.Factory accountLoaderFactory;
private final ImmutableSet<ListChangesOption> options;
private final ChangeMessagesUtil cmUtil;
private final StarredChangesReader starredChangesreader;
private final Provider<ConsistencyChecker> 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> 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<CurrentUser> user,
PermissionBackend permissionBackend,
ChangeData.Factory cdf,
AccountLoader.Factory ailf,
ChangeMessagesUtil cmUtil,
StarredChangesReader starredChangesreader,
Provider<ConsistencyChecker> checkerProvider,
ActionJson actionJson,
ChangeNotes.Factory notesFactory,
LabelsJson labelsJson,
RemoveReviewerControl removeReviewerControl,
TrackingFooters trackingFooters,
Metrics metrics,
RevisionJson.Factory revisionJsonFactory,
@GerritServerConfig Config cfg,
@Assisted Iterable<ListChangesOption> options,
@Assisted Optional<PluginDefinedInfosFactory> 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<List<ChangeInfo>> format(List<QueryResult<ChangeData>> in)
throws PermissionBackendException {
try (Timer0.Context ignored = metrics.formatQueryResultsLatency.start()) {
accountLoader = accountLoaderFactory.create(has(DETAILED_ACCOUNTS));
List<List<ChangeInfo>> res = new ArrayList<>(in.size());
Map<Change.Id, ChangeInfo> cache = Maps.newHashMapWithExpectedSize(in.size());
ImmutableListMultimap<Change.Id, PluginDefinedInfo> pluginInfosByChange =
getPluginInfos(in.stream().flatMap(e -> e.entities().stream()).collect(toList()));
for (QueryResult<ChangeData> r : in) {
List<ChangeInfo> 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<ChangeInfo> format(Collection<ChangeData> in) throws PermissionBackendException {
accountLoader = accountLoaderFactory.create(has(DETAILED_ACCOUNTS));
ensureLoaded(in);
List<ChangeInfo> out = new ArrayList<>(in.size());
ImmutableListMultimap<Change.Id, PluginDefinedInfo> 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<LegacySubmitRequirementInfo> requirementsFor(ChangeData cd) {
try (TraceTimer timer =
TraceContext.newTimer(
"Get requirements", Metadata.builder().changeId(cd.change().getId().get()).build())) {
List<LegacySubmitRequirementInfo> 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<SubmitRecordInfo> submitRecordsFor(ChangeData cd) {
try (TraceTimer timer =
TraceContext.newTimer(
"Get submit records", Metadata.builder().changeId(cd.change().getId().get()).build())) {
List<SubmitRecordInfo> submitRecordInfos = new ArrayList<>();
for (SubmitRecord record : cd.submitRecords(SUBMIT_RULE_OPTIONS_STRICT)) {
submitRecordInfos.add(submitRecordToInfo(record));
}
return submitRecordInfos;
}
}
private List<SubmitRequirementResultInfo> submitRequirementsFor(ChangeData cd) {
try (TraceTimer timer =
TraceContext.newTimer(
"Get submit requirements",
Metadata.builder().changeId(cd.change().getId().get()).build())) {
List<SubmitRequirementResultInfo> 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<ListChangesOption> set, ImmutableSet<ListChangesOption> toFind) {
return !Sets.intersection(toFind, set).isEmpty();
}
private ChangeInfo format(
ChangeData cd,
Optional<PatchSet.Id> limitToPsId,
boolean fillAccountLoader,
List<PluginDefinedInfo> 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<ChangeData> 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<ChangeInfo> toChangeInfos(
List<ChangeData> changes,
Map<Change.Id, ChangeInfo> cache,
ImmutableListMultimap<Change.Id, PluginDefinedInfo> pluginInfosByChange) {
try (Timer0.Context ignored = metrics.toChangeInfosLatency.start()) {
List<ChangeInfo> 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<ChangeInfo> 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 =
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<PatchSet.Id> limitToPsId,
List<PluginDefinedInfo> pluginInfosForChange)
throws PatchListNotAvailableException, GpgException, PermissionBackendException, IOException {
try (Timer0.Context ignored = metrics.toChangeInfoLatency.start()) {
return toChangeInfoImpl(cd, limitToPsId, pluginInfosForChange);
}
}
private ChangeInfo toChangeInfoImpl(
ChangeData cd, Optional<PatchSet.Id> limitToPsId, List<PluginDefinedInfo> 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> 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<PatchSet.Id, PatchSet> 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<String, RevisionInfo> 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<String, String> 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<ReviewerState, Collection<AccountInfo>> reviewerMap(
ReviewerSet reviewers, ReviewerByEmailSet reviewersByEmail, boolean includeRemoved) {
try (TraceTimer timer = TraceContext.newTimer("Get reviewer map", Metadata.empty())) {
Map<ReviewerState, Collection<AccountInfo>> reviewerMap = new HashMap<>();
for (ReviewerStateInternal state : ReviewerStateInternal.values()) {
if (!includeRemoved && state == ReviewerStateInternal.REMOVED) {
continue;
}
List<AccountInfo> reviewersByState = toAccountInfo(reviewers.byState(state));
reviewersByState.addAll(toAccountInfoByEmail(reviewersByEmail.byState(state)));
if (!reviewersByState.isEmpty()) {
reviewerMap.put(state.asReviewerState(), reviewersByState);
}
}
return reviewerMap;
}
}
private List<ReviewerUpdateInfo> reviewerUpdates(ChangeData cd) {
try (TraceTimer timer =
TraceContext.newTimer(
"Get reviewer updates",
Metadata.builder().changeId(cd.change().getId().get()).build())) {
List<ReviewerStatusUpdate> reviewerUpdates = cd.reviewerUpdates();
List<ReviewerUpdateInfo> 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<ObjectId> 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<PatchSetApproval> s = cd.getSubmitApproval();
if (!s.isPresent()) {
return;
}
out.setSubmitted(s.get().granted(), accountLoader.get(s.get().accountId()));
}
}
private ImmutableList<ChangeMessageInfo> messages(ChangeData cd) {
try (TraceTimer timer =
TraceContext.newTimer(
"Get messages", Metadata.builder().changeId(cd.change().getId().get()).build())) {
List<ChangeMessage> messages = cmUtil.byChange(cd.notes());
if (messages.isEmpty()) {
return ImmutableList.of();
}
List<ChangeMessageInfo> result = Lists.newArrayListWithCapacity(messages.size());
for (ChangeMessage message : messages) {
result.add(createChangeMessageInfo(message, accountLoader));
}
return ImmutableList.copyOf(result);
}
}
private List<AccountInfo> 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<LabelInfo> labels = out.labels.values();
Set<Account.Id> fixed = Sets.newHashSetWithExpectedSize(labels.size());
Set<Account.Id> 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<AccountInfo> 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<AccountInfo> result = Lists.newArrayListWithCapacity(removable.size());
for (Account.Id id : removable) {
result.add(accountLoader.get(id));
}
// Reviewers added by email are always removable
for (Collection<AccountInfo> infos : out.reviewers.values()) {
for (AccountInfo info : infos) {
if (info._accountId == null) {
result.add(info);
}
}
}
return result;
}
}
private List<AccountInfo> toAccountInfo(Collection<Account.Id> 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<AccountInfo> toAccountInfoByEmail(Collection<Address> addresses) {
return addresses.stream()
.map(this::toAccountInfoByEmail)
.sorted(AccountInfoComparator.ORDER_NULLS_FIRST)
.collect(toList());
}
private ImmutableMap<PatchSet.Id, PatchSet> loadPatchSets(
ChangeData cd, Optional<PatchSet.Id> limitToPsId) {
try (TraceTimer timer =
TraceContext.newTimer(
"Load patch sets", Metadata.builder().changeId(cd.change().getId().get()).build())) {
Collection<PatchSet> 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<PatchSet.Id, PatchSet> map = ImmutableSortedMap.naturalOrder();
for (PatchSet patchSet : src) {
map.put(patchSet.id(), patchSet);
}
return map.build();
}
}
/** Populate the 'starred' field. */
private void populateStarField(List<ChangeInfo> 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<Change.Id> changeIds =
changeInfos.stream().map(c -> Change.id(c.virtualIdNumber)).collect(Collectors.toList());
Set<Change.Id> 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<PluginDefinedInfo> getPluginInfos(ChangeData cd) {
return getPluginInfos(Collections.singleton(cd)).get(cd.getId());
}
private ImmutableListMultimap<Change.Id, PluginDefinedInfo> getPluginInfos(
Collection<ChangeData> 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.
*
* <p>Few fields are populated: project, branch, changeId, _number, subject, owner.
*/
private static Optional<ChangeInfo> 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;
}
}