blob: 7aefa6329d69d360c01d2c3530418360d151e48e [file] [log] [blame]
// Copyright (C) 2013 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.index.change;
import static com.google.common.base.MoreObjects.firstNonNull;
import static com.google.common.collect.ImmutableList.toImmutableList;
import static com.google.common.collect.ImmutableListMultimap.toImmutableListMultimap;
import static com.google.common.collect.ImmutableSet.toImmutableSet;
import static com.google.gerrit.index.FieldDef.exact;
import static com.google.gerrit.index.FieldDef.integer;
import static com.google.gerrit.index.FieldDef.prefix;
import static com.google.gerrit.index.FieldDef.storedOnly;
import static com.google.gerrit.index.FieldDef.timestamp;
import static com.google.gerrit.server.util.AttentionSetUtil.additionsOnly;
import static java.nio.charset.StandardCharsets.UTF_8;
import static java.util.stream.Collectors.joining;
import static java.util.stream.Collectors.toList;
import static java.util.stream.Collectors.toSet;
import com.google.common.annotations.VisibleForTesting;
import com.google.common.base.Splitter;
import com.google.common.collect.HashBasedTable;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableMap;
import com.google.common.collect.ImmutableSet;
import com.google.common.collect.ImmutableTable;
import com.google.common.collect.Iterables;
import com.google.common.collect.Lists;
import com.google.common.collect.Table;
import com.google.common.flogger.FluentLogger;
import com.google.common.io.Files;
import com.google.common.primitives.Longs;
import com.google.gerrit.common.Nullable;
import com.google.gerrit.entities.Account;
import com.google.gerrit.entities.Address;
import com.google.gerrit.entities.AttentionSetUpdate;
import com.google.gerrit.entities.Change;
import com.google.gerrit.entities.ChangeMessage;
import com.google.gerrit.entities.LabelType;
import com.google.gerrit.entities.LegacySubmitRequirement;
import com.google.gerrit.entities.PatchSetApproval;
import com.google.gerrit.entities.Project;
import com.google.gerrit.entities.RefNames;
import com.google.gerrit.entities.SubmitRecord;
import com.google.gerrit.entities.SubmitRequirementResult;
import com.google.gerrit.entities.converter.ChangeProtoConverter;
import com.google.gerrit.entities.converter.PatchSetApprovalProtoConverter;
import com.google.gerrit.entities.converter.PatchSetProtoConverter;
import com.google.gerrit.entities.converter.ProtoConverter;
import com.google.gerrit.index.FieldDef;
import com.google.gerrit.index.IndexedField;
import com.google.gerrit.index.RefState;
import com.google.gerrit.index.SchemaFieldDefs;
import com.google.gerrit.index.SchemaUtil;
import com.google.gerrit.json.OutputFormat;
import com.google.gerrit.proto.Protos;
import com.google.gerrit.server.ReviewerByEmailSet;
import com.google.gerrit.server.ReviewerSet;
import com.google.gerrit.server.StarredChangesUtil;
import com.google.gerrit.server.config.AllUsersName;
import com.google.gerrit.server.index.change.StalenessChecker.RefStatePattern;
import com.google.gerrit.server.notedb.ReviewerStateInternal;
import com.google.gerrit.server.notedb.SubmitRequirementProtoConverter;
import com.google.gerrit.server.project.SubmitRuleOptions;
import com.google.gerrit.server.query.change.ChangeData;
import com.google.gerrit.server.query.change.ChangeQueryBuilder;
import com.google.gerrit.server.query.change.ChangeStatusPredicate;
import com.google.gerrit.server.query.change.MagicLabelValue;
import com.google.gson.Gson;
import com.google.protobuf.MessageLite;
import java.sql.Timestamp;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.HashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.function.Function;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import java.util.stream.StreamSupport;
import org.eclipse.jgit.lib.PersonIdent;
/**
* Fields indexed on change documents.
*
* <p>Each field corresponds to both a field name supported by {@link ChangeQueryBuilder} for
* querying that field, and a method on {@link ChangeData} used for populating the corresponding
* document fields in the secondary index.
*
* <p>Field names are all lowercase alphanumeric plus underscore; index implementations may create
* unambiguous derived field names containing other characters.
*
* <p>Note that this class does not override {@link Object#equals(Object)}. It relies on instances
* being singletons so that the default (i.e. reference) comparison works.
*/
public class ChangeField {
private static final FluentLogger logger = FluentLogger.forEnclosingClass();
public static final int NO_ASSIGNEE = -1;
private static final Gson GSON = OutputFormat.JSON_COMPACT.newGson();
/**
* To avoid the non-google dependency on org.apache.lucene.index.IndexWriter.MAX_TERM_LENGTH it is
* redefined here.
*/
public static final int MAX_TERM_LENGTH = (1 << 15) - 2;
// TODO: Rename LEGACY_ID to NUMERIC_ID
/** Legacy change ID. */
public static final FieldDef<ChangeData, String> LEGACY_ID_STR =
exact("legacy_id_str").stored().build(cd -> String.valueOf(cd.getId().get()));
/** Newer style Change-Id key. */
public static final FieldDef<ChangeData, String> ID =
prefix(ChangeQueryBuilder.FIELD_CHANGE_ID).build(changeGetter(c -> c.getKey().get()));
/** Change status string, in the same format as {@code status:}. */
public static final IndexedField<ChangeData, String> STATUS_FIELD =
IndexedField.<ChangeData>stringBuilder("Status")
.required()
.size(20)
.build(changeGetter(c -> ChangeStatusPredicate.canonicalize(c.getStatus())));
public static final IndexedField<ChangeData, String>.SearchSpec STATUS_SPEC =
STATUS_FIELD.exact(ChangeQueryBuilder.FIELD_STATUS);
/** Project containing the change. */
public static final IndexedField<ChangeData, String> PROJECT_FIELD =
IndexedField.<ChangeData>stringBuilder("Project")
.required()
.stored()
.size(200)
.build(changeGetter(c -> c.getProject().get()));
public static final IndexedField<ChangeData, String>.SearchSpec PROJECT_SPEC =
PROJECT_FIELD.exact(ChangeQueryBuilder.FIELD_PROJECT);
/** Project containing the change, as a prefix field. */
public static final IndexedField<ChangeData, String>.SearchSpec PROJECTS_SPEC =
PROJECT_FIELD.prefix(ChangeQueryBuilder.FIELD_PROJECTS);
/** Reference (aka branch) the change will submit onto. */
public static final IndexedField<ChangeData, String> REF_FIELD =
IndexedField.<ChangeData>stringBuilder("Ref")
.required()
.size(300)
.build(changeGetter(c -> c.getDest().branch()));
public static final IndexedField<ChangeData, String>.SearchSpec REF_SPEC =
REF_FIELD.exact(ChangeQueryBuilder.FIELD_REF);
/** Topic, a short annotation on the branch. */
public static final IndexedField<ChangeData, String> TOPIC_FIELD =
IndexedField.<ChangeData>stringBuilder("Topic").size(500).build(ChangeField::getTopic);
public static final IndexedField<ChangeData, String>.SearchSpec EXACT_TOPIC =
TOPIC_FIELD.exact("topic4");
/** Topic, a short annotation on the branch. */
public static final IndexedField<ChangeData, String>.SearchSpec FUZZY_TOPIC =
TOPIC_FIELD.fullText("topic5");
/** Topic, a short annotation on the branch. */
public static final IndexedField<ChangeData, String>.SearchSpec PREFIX_TOPIC =
TOPIC_FIELD.prefix("topic6");
/** {@link com.google.gerrit.entities.SubmissionId} assigned by MergeOp. */
public static final IndexedField<ChangeData, String> SUBMISSIONID_FIELD =
IndexedField.<ChangeData>stringBuilder("SubmissionId")
.size(500)
.build(changeGetter(Change::getSubmissionId));
public static final IndexedField<ChangeData, String>.SearchSpec SUBMISSIONID_SPEC =
SUBMISSIONID_FIELD.exact(ChangeQueryBuilder.FIELD_SUBMISSIONID);
/** Last update time since January 1, 1970. */
// TODO(issue-15518): Migrate type for timestamp index fields from Timestamp to Instant
public static final FieldDef<ChangeData, Timestamp> UPDATED =
timestamp("updated2")
.stored()
.build(changeGetter(change -> Timestamp.from(change.getLastUpdatedOn())));
/** When this change was merged, time since January 1, 1970. */
// TODO(issue-15518): Migrate type for timestamp index fields from Timestamp to Instant
public static final IndexedField<ChangeData, Timestamp> MERGED_ON_FIELD =
IndexedField.<ChangeData>timestampBuilder("MergedOn")
.stored()
.build(
cd -> cd.getMergedOn().map(Timestamp::from).orElse(null),
(cd, field) -> cd.setMergedOn(field != null ? field.toInstant() : null));
public static final IndexedField<ChangeData, Timestamp>.SearchSpec MERGED_ON_SPEC =
MERGED_ON_FIELD.timestamp(ChangeQueryBuilder.FIELD_MERGED_ON);
/** List of full file paths modified in the current patch set. */
public static final IndexedField<ChangeData, Iterable<String>> PATH_FIELD =
// Named for backwards compatibility.
IndexedField.<ChangeData>iterableStringBuilder("File")
.build(cd -> firstNonNull(cd.currentFilePaths(), ImmutableList.of()));
public static final IndexedField<ChangeData, Iterable<String>>.SearchSpec PATH_SPEC =
PATH_FIELD
// Named for backwards compatibility.
.exact(ChangeQueryBuilder.FIELD_FILE);
public static Set<String> getFileParts(ChangeData cd) {
List<String> paths = cd.currentFilePaths();
Splitter s = Splitter.on('/').omitEmptyStrings();
Set<String> r = new HashSet<>();
for (String path : paths) {
for (String part : s.split(path)) {
r.add(part);
}
}
return r;
}
/** Hashtags tied to a change */
public static final IndexedField<ChangeData, Iterable<String>> HASHTAG_FIELD =
IndexedField.<ChangeData>iterableStringBuilder("Hashtag")
.size(200)
.build(cd -> cd.hashtags().stream().map(String::toLowerCase).collect(toSet()));
public static final IndexedField<ChangeData, Iterable<String>>.SearchSpec HASHTAG_SPEC =
HASHTAG_FIELD.exact(ChangeQueryBuilder.FIELD_HASHTAG);
/** Hashtags as fulltext field for in-string search. */
public static final IndexedField<ChangeData, Iterable<String>>.SearchSpec FUZZY_HASHTAG =
HASHTAG_FIELD.fullText("hashtag2");
/** Hashtags as prefix field for in-string search. */
public static final IndexedField<ChangeData, Iterable<String>>.SearchSpec PREFIX_HASHTAG =
HASHTAG_FIELD.prefix("hashtag3");
/** Hashtags with original case. */
public static final IndexedField<ChangeData, Iterable<byte[]>> HASHTAG_CASE_AWARE_FIELD =
IndexedField.<ChangeData>iterableByteArrayBuilder("HashtagCaseAware")
.stored()
.build(
cd -> cd.hashtags().stream().map(t -> t.getBytes(UTF_8)).collect(toSet()),
(cd, field) ->
cd.setHashtags(
StreamSupport.stream(field.spliterator(), false)
.map(f -> new String(f, UTF_8))
.collect(toImmutableSet())));
public static final IndexedField<ChangeData, Iterable<byte[]>>.SearchSpec
HASHTAG_CASE_AWARE_SPEC = HASHTAG_CASE_AWARE_FIELD.storedOnly("_hashtag");
/** Components of each file path modified in the current patch set. */
public static final IndexedField<ChangeData, Iterable<String>> FILE_PART_FIELD =
IndexedField.<ChangeData>iterableStringBuilder("FilePart").build(ChangeField::getFileParts);
public static final IndexedField<ChangeData, Iterable<String>>.SearchSpec FILE_PART_SPEC =
FILE_PART_FIELD.exact(ChangeQueryBuilder.FIELD_FILEPART);
/** File extensions of each file modified in the current patch set. */
public static final IndexedField<ChangeData, Iterable<String>> EXTENSION_FIELD =
IndexedField.<ChangeData>iterableStringBuilder("Extension")
.size(100)
.build(ChangeField::getExtensions);
public static final IndexedField<ChangeData, Iterable<String>>.SearchSpec EXTENSION_SPEC =
EXTENSION_FIELD.exact(ChangeQueryBuilder.FIELD_EXTENSION);
public static Set<String> getExtensions(ChangeData cd) {
return extensions(cd).collect(toSet());
}
/**
* File extensions of each file modified in the current patch set as a sorted list. The purpose of
* this field is to allow matching changes that only touch files with certain file extensions.
*/
public static final IndexedField<ChangeData, String> ONLY_EXTENSIONS_FIELD =
IndexedField.<ChangeData>stringBuilder("OnlyExtensions")
.build(ChangeField::getAllExtensionsAsList);
public static final IndexedField<ChangeData, String>.SearchSpec ONLY_EXTENSIONS_SPEC =
ONLY_EXTENSIONS_FIELD.exact(ChangeQueryBuilder.FIELD_ONLY_EXTENSIONS);
public static String getAllExtensionsAsList(ChangeData cd) {
return extensions(cd).distinct().sorted().collect(joining(","));
}
/**
* Returns a stream with all file extensions that are used by files in the given change. A file
* extension is defined as the portion of the filename following the final `.`. Files with no `.`
* in their name have no extension. For them an empty string is returned as part of the stream.
*
* <p>If the change contains multiple files with the same extension the extension is returned
* multiple times in the stream (once per file).
*/
private static Stream<String> extensions(ChangeData cd) {
return cd.currentFilePaths().stream()
// Use case-insensitive file extensions even though other file fields are case-sensitive.
// If we want to find "all Java files", we want to match both .java and .JAVA, even if we
// normally care about case sensitivity. (Whether we should change the existing file/path
// predicates to be case insensitive is a separate question.)
.map(f -> Files.getFileExtension(f).toLowerCase(Locale.US));
}
/** Footers from the commit message of the current patch set. */
public static final IndexedField<ChangeData, Iterable<String>> FOOTER_FIELD =
IndexedField.<ChangeData>iterableStringBuilder("Footer").build(ChangeField::getFooters);
public static final IndexedField<ChangeData, Iterable<String>>.SearchSpec FOOTER_SPEC =
FOOTER_FIELD.exact(ChangeQueryBuilder.FIELD_FOOTER);
public static Set<String> getFooters(ChangeData cd) {
return cd.commitFooters().stream()
.map(f -> f.toString().toLowerCase(Locale.US))
.collect(toSet());
}
/** Footers from the commit message of the current patch set. */
public static final IndexedField<ChangeData, Iterable<String>> FOOTER_NAME_FIELD =
IndexedField.<ChangeData>iterableStringBuilder("FooterName")
.build(ChangeField::getFootersNames);
public static final IndexedField<ChangeData, Iterable<String>>.SearchSpec FOOTER_NAME =
FOOTER_NAME_FIELD.exact(ChangeQueryBuilder.FIELD_FOOTER_NAME);
public static Set<String> getFootersNames(ChangeData cd) {
return cd.commitFooters().stream().map(f -> f.getKey()).collect(toSet());
}
/** Folders that are touched by the current patch set. */
public static final IndexedField<ChangeData, Iterable<String>> DIRECTORY_FIELD =
IndexedField.<ChangeData>iterableStringBuilder("Directory")
.build(ChangeField::getDirectories);
public static final IndexedField<ChangeData, Iterable<String>>.SearchSpec DIRECTORY_SPEC =
DIRECTORY_FIELD.exact(ChangeQueryBuilder.FIELD_DIRECTORY);
public static Set<String> getDirectories(ChangeData cd) {
List<String> paths = cd.currentFilePaths();
Splitter s = Splitter.on('/').omitEmptyStrings();
Set<String> r = new HashSet<>();
for (String path : paths) {
StringBuilder directory = new StringBuilder();
r.add(directory.toString());
String nextPart = null;
for (String part : s.split(path.toLowerCase(Locale.US))) {
if (nextPart != null) {
r.add(nextPart);
if (directory.length() > 0) {
directory.append("/");
}
directory.append(nextPart);
String intermediateDir = directory.toString();
int i = intermediateDir.indexOf('/');
while (i >= 0) {
r.add(intermediateDir);
intermediateDir = intermediateDir.substring(i + 1);
i = intermediateDir.indexOf('/');
}
}
nextPart = part;
}
}
return r;
}
/** Owner/creator of the change. */
public static final IndexedField<ChangeData, Integer> OWNER_FIELD =
IndexedField.<ChangeData>integerBuilder("Owner")
.required()
.build(changeGetter(c -> c.getOwner().get()));
public static final IndexedField<ChangeData, Integer>.SearchSpec OWNER_SPEC =
OWNER_FIELD.integer(ChangeQueryBuilder.FIELD_OWNER);
/** Uploader of the latest patch set. */
public static final IndexedField<ChangeData, Integer> UPLOADER_FIELD =
IndexedField.<ChangeData>integerBuilder("Uploader")
.required()
.build(cd -> cd.currentPatchSet().uploader().get());
public static final IndexedField<ChangeData, Integer>.SearchSpec UPLOADER_SPEC =
UPLOADER_FIELD.integer(ChangeQueryBuilder.FIELD_UPLOADER);
/** References the source change number that this change was cherry-picked from. */
public static final IndexedField<ChangeData, Integer> CHERRY_PICK_OF_CHANGE_FIELD =
IndexedField.<ChangeData>integerBuilder("CherryPickOfChange")
.build(
cd ->
cd.change().getCherryPickOf() != null
? cd.change().getCherryPickOf().changeId().get()
: null);
public static final IndexedField<ChangeData, Integer>.SearchSpec CHERRY_PICK_OF_CHANGE =
CHERRY_PICK_OF_CHANGE_FIELD.integer(ChangeQueryBuilder.FIELD_CHERRY_PICK_OF_CHANGE);
/** References the source change patch-set that this change was cherry-picked from. */
public static final IndexedField<ChangeData, Integer> CHERRY_PICK_OF_PATCHSET_FIELD =
IndexedField.<ChangeData>integerBuilder("CherryPickOfPatchset")
.build(
cd ->
cd.change().getCherryPickOf() != null
? cd.change().getCherryPickOf().get()
: null);
public static final IndexedField<ChangeData, Integer>.SearchSpec CHERRY_PICK_OF_PATCHSET =
CHERRY_PICK_OF_PATCHSET_FIELD.integer(ChangeQueryBuilder.FIELD_CHERRY_PICK_OF_PATCHSET);
/** This class decouples the internal and API types from storage. */
private static class StoredAttentionSetEntry {
final long timestampMillis;
final int userId;
final String reason;
final AttentionSetUpdate.Operation operation;
StoredAttentionSetEntry(AttentionSetUpdate attentionSetUpdate) {
timestampMillis = attentionSetUpdate.timestamp().toEpochMilli();
userId = attentionSetUpdate.account().get();
reason = attentionSetUpdate.reason();
operation = attentionSetUpdate.operation();
}
AttentionSetUpdate toAttentionSetUpdate() {
return AttentionSetUpdate.createFromRead(
Instant.ofEpochMilli(timestampMillis), Account.id(userId), operation, reason);
}
}
/**
* Users included in the attention set of the change. This omits timestamp, reason and possible
* future fields.
*
* @see #ATTENTION_SET_FULL_SPEC
*/
public static final IndexedField<ChangeData, Iterable<Integer>> ATTENTION_SET_USERS_FIELD =
IndexedField.<ChangeData>iterableIntegerBuilder("AttentionSetUsers")
.build(ChangeField::getAttentionSetUserIds);
public static final IndexedField<ChangeData, Iterable<Integer>>.SearchSpec ATTENTION_SET_USERS =
ATTENTION_SET_USERS_FIELD.integer(ChangeQueryBuilder.FIELD_ATTENTION_SET_USERS);
/** Number of changes that contain attention set. */
public static final IndexedField<ChangeData, Integer> ATTENTION_SET_USERS_COUNT_FIELD =
IndexedField.<ChangeData>integerBuilder("AttentionSetUsersCount")
.stored()
.build(cd -> additionsOnly(cd.attentionSet()).size());
public static final IndexedField<ChangeData, Integer>.SearchSpec ATTENTION_SET_USERS_COUNT =
ATTENTION_SET_USERS_COUNT_FIELD.integerRange(
ChangeQueryBuilder.FIELD_ATTENTION_SET_USERS_COUNT);
/**
* The full attention set data including timestamp, reason and possible future fields.
*
* @see #ATTENTION_SET_USERS
*/
public static final IndexedField<ChangeData, Iterable<byte[]>> ATTENTION_SET_FULL_FIELD =
IndexedField.<ChangeData>iterableByteArrayBuilder("AttentionSetFull")
.stored()
.required()
.build(
ChangeField::storedAttentionSet,
(cd, value) ->
parseAttentionSet(
StreamSupport.stream(value.spliterator(), false)
.map(v -> new String(v, UTF_8))
.collect(toImmutableSet()),
cd));
public static final IndexedField<ChangeData, Iterable<byte[]>>.SearchSpec
ATTENTION_SET_FULL_SPEC =
ATTENTION_SET_FULL_FIELD.storedOnly(ChangeQueryBuilder.FIELD_ATTENTION_SET_FULL);
/** The user assigned to the change. */
public static final IndexedField<ChangeData, Integer> ASSIGNEE_FIELD =
IndexedField.<ChangeData>integerBuilder("Assignee")
.build(changeGetter(c -> c.getAssignee() != null ? c.getAssignee().get() : NO_ASSIGNEE));
public static final IndexedField<ChangeData, Integer>.SearchSpec ASSIGNEE_SPEC =
ASSIGNEE_FIELD.integer(ChangeQueryBuilder.FIELD_ASSIGNEE);
/** Reviewer(s) associated with the change. */
public static final IndexedField<ChangeData, Iterable<String>> REVIEWER_FIELD =
IndexedField.<ChangeData>iterableStringBuilder("Reviewer")
.stored()
.build(
cd -> getReviewerFieldValues(cd.reviewers()),
(cd, field) -> cd.setReviewers(parseReviewerFieldValues(cd.getId(), field)));
public static final IndexedField<ChangeData, Iterable<String>>.SearchSpec REVIEWER_SPEC =
REVIEWER_FIELD.exact("reviewer2");
/** Reviewer(s) associated with the change that do not have a gerrit account. */
public static final IndexedField<ChangeData, Iterable<String>> REVIEWER_BY_EMAIL_FIELD =
IndexedField.<ChangeData>iterableStringBuilder("ReviewerByEmail")
.stored()
.build(
cd -> getReviewerByEmailFieldValues(cd.reviewersByEmail()),
(cd, field) ->
cd.setReviewersByEmail(parseReviewerByEmailFieldValues(cd.getId(), field)));
public static final IndexedField<ChangeData, Iterable<String>>.SearchSpec REVIEWER_BY_EMAIL =
REVIEWER_BY_EMAIL_FIELD.exact("reviewer_by_email");
/** Reviewer(s) modified during change's current WIP phase. */
public static final IndexedField<ChangeData, Iterable<String>> PENDING_REVIEWER_FIELD =
IndexedField.<ChangeData>iterableStringBuilder("PendingReviewer")
.stored()
.build(
cd -> getReviewerFieldValues(cd.pendingReviewers()),
(cd, field) -> cd.setPendingReviewers(parseReviewerFieldValues(cd.getId(), field)));
public static final IndexedField<ChangeData, Iterable<String>>.SearchSpec PENDING_REVIEWER_SPEC =
PENDING_REVIEWER_FIELD.exact(ChangeQueryBuilder.FIELD_PENDING_REVIEWER);
/** Reviewer(s) by email modified during change's current WIP phase. */
public static final IndexedField<ChangeData, Iterable<String>> PENDING_REVIEWER_BY_EMAIL_FIELD =
IndexedField.<ChangeData>iterableStringBuilder("PendingReviewerByEmail")
.stored()
.build(
cd -> getReviewerByEmailFieldValues(cd.pendingReviewersByEmail()),
(cd, field) ->
cd.setPendingReviewersByEmail(
parseReviewerByEmailFieldValues(cd.getId(), field)));
public static final IndexedField<ChangeData, Iterable<String>>.SearchSpec
PENDING_REVIEWER_BY_EMAIL =
PENDING_REVIEWER_BY_EMAIL_FIELD.exact(ChangeQueryBuilder.FIELD_PENDING_REVIEWER_BY_EMAIL);
/** References a change that this change reverts. */
public static final IndexedField<ChangeData, Integer> REVERT_OF_FIELD =
IndexedField.<ChangeData>integerBuilder("RevertOf")
.build(cd -> cd.change().getRevertOf() != null ? cd.change().getRevertOf().get() : null);
public static final IndexedField<ChangeData, Integer>.SearchSpec REVERT_OF =
REVERT_OF_FIELD.integer(ChangeQueryBuilder.FIELD_REVERTOF);
public static final IndexedField<ChangeData, String> IS_PURE_REVERT_FIELD =
IndexedField.<ChangeData>stringBuilder("IsPureRevert")
.build(cd -> Boolean.TRUE.equals(cd.isPureRevert()) ? "1" : "0");
public static final IndexedField<ChangeData, String>.SearchSpec IS_PURE_REVERT_SPEC =
IS_PURE_REVERT_FIELD.fullText(ChangeQueryBuilder.FIELD_PURE_REVERT);
/**
* Determines if a change is submittable based on {@link
* com.google.gerrit.entities.SubmitRequirement}s.
*/
public static final IndexedField<ChangeData, String> IS_SUBMITTABLE_FIELD =
IndexedField.<ChangeData>stringBuilder("IsSubmittable")
.build(
cd ->
// All submit requirements should be fulfilled
cd.submitRequirementsIncludingLegacy().values().stream()
.allMatch(SubmitRequirementResult::fulfilled)
? "1"
: "0");
public static final IndexedField<ChangeData, String>.SearchSpec IS_SUBMITTABLE_SPEC =
IS_SUBMITTABLE_FIELD.exact(ChangeQueryBuilder.FIELD_IS_SUBMITTABLE);
@VisibleForTesting
static List<String> getReviewerFieldValues(ReviewerSet reviewers) {
List<String> r = new ArrayList<>(reviewers.asTable().size() * 2);
for (Table.Cell<ReviewerStateInternal, Account.Id, Instant> c : reviewers.asTable().cellSet()) {
String v = getReviewerFieldValue(c.getRowKey(), c.getColumnKey());
r.add(v);
r.add(v + ',' + c.getValue().toEpochMilli());
}
return r;
}
public static String getReviewerFieldValue(ReviewerStateInternal state, Account.Id id) {
return state.toString() + ',' + id;
}
@VisibleForTesting
static List<String> getReviewerByEmailFieldValues(ReviewerByEmailSet reviewersByEmail) {
List<String> r = new ArrayList<>(reviewersByEmail.asTable().size() * 2);
for (Table.Cell<ReviewerStateInternal, Address, Instant> c :
reviewersByEmail.asTable().cellSet()) {
String v = getReviewerByEmailFieldValue(c.getRowKey(), c.getColumnKey());
r.add(v);
if (c.getColumnKey().name() != null) {
// Add another entry without the name to provide search functionality on the email
Address emailOnly = Address.create(c.getColumnKey().email());
r.add(getReviewerByEmailFieldValue(c.getRowKey(), emailOnly));
}
r.add(v + ',' + c.getValue().toEpochMilli());
}
return r;
}
public static String getReviewerByEmailFieldValue(ReviewerStateInternal state, Address adr) {
return state.toString() + ',' + adr;
}
public static ReviewerSet parseReviewerFieldValues(Change.Id changeId, Iterable<String> values) {
ImmutableTable.Builder<ReviewerStateInternal, Account.Id, Instant> b = ImmutableTable.builder();
for (String v : values) {
int i = v.indexOf(',');
if (i < 0) {
logger.atWarning().log(
"Invalid value for reviewer field from change %s: %s", changeId.get(), v);
continue;
}
int i2 = v.lastIndexOf(',');
if (i2 == i) {
// Don't log a warning here.
// For each reviewer we store 2 values in the reviewer field, one value with the format
// "<reviewer-type>,<account-id>" and one value with the format
// "<reviewer-type>,<account-id>,<timestamp>" (see #getReviewerFieldValues(ReviewerSet)).
// For parsing we are only interested in the "<reviewer-type>,<account-id>,<timestamp>"
// value and the "<reviewer-type>,<account-id>" value is ignored here.
continue;
}
Optional<ReviewerStateInternal> reviewerState = getReviewerState(v.substring(0, i));
if (!reviewerState.isPresent()) {
logger.atWarning().log(
"Failed to parse reviewer state of reviewer field from change %s: %s",
changeId.get(), v);
continue;
}
Optional<Account.Id> accountId = Account.Id.tryParse(v.substring(i + 1, i2));
if (!accountId.isPresent()) {
logger.atWarning().log(
"Failed to parse account ID of reviewer field from change %s: %s", changeId.get(), v);
continue;
}
Long l = Longs.tryParse(v.substring(i2 + 1));
if (l == null) {
logger.atWarning().log(
"Failed to parse timestamp of reviewer field from change %s: %s", changeId.get(), v);
continue;
}
Instant timestamp = Instant.ofEpochMilli(l);
b.put(reviewerState.get(), accountId.get(), timestamp);
}
return ReviewerSet.fromTable(b.build());
}
public static ReviewerByEmailSet parseReviewerByEmailFieldValues(
Change.Id changeId, Iterable<String> values) {
ImmutableTable.Builder<ReviewerStateInternal, Address, Instant> b = ImmutableTable.builder();
for (String v : values) {
int i = v.indexOf(',');
if (i < 0) {
logger.atWarning().log(
"Invalid value for reviewer by email field from change %s: %s", changeId.get(), v);
continue;
}
int i2 = v.lastIndexOf(',');
if (i2 == i) {
// Don't log a warning here.
// For each reviewer we store 2 values in the reviewer field, one value with the format
// "<reviewer-type>,<email>" and one value with the format
// "<reviewer-type>,<email>,<timestamp>" (see
// #getReviewerByEmailFieldValues(ReviewerByEmailSet)).
// For parsing we are only interested in the "<reviewer-type>,<email>,<timestamp>" value
// and the "<reviewer-type>,<email>" value is ignored here.
continue;
}
Optional<ReviewerStateInternal> reviewerState = getReviewerState(v.substring(0, i));
if (!reviewerState.isPresent()) {
logger.atWarning().log(
"Failed to parse reviewer state of reviewer by email field from change %s: %s",
changeId.get(), v);
continue;
}
Address address = Address.tryParse(v.substring(i + 1, i2));
if (address == null) {
logger.atWarning().log(
"Failed to parse address of reviewer by email field from change %s: %s",
changeId.get(), v);
continue;
}
Long l = Longs.tryParse(v.substring(i2 + 1));
if (l == null) {
logger.atWarning().log(
"Failed to parse timestamp of reviewer by email field from change %s: %s",
changeId.get(), v);
continue;
}
Instant timestamp = Instant.ofEpochMilli(l);
b.put(reviewerState.get(), address, timestamp);
}
return ReviewerByEmailSet.fromTable(b.build());
}
private static Optional<ReviewerStateInternal> getReviewerState(String value) {
try {
return Optional.of(ReviewerStateInternal.valueOf(value));
} catch (IllegalArgumentException | NullPointerException e) {
return Optional.empty();
}
}
private static ImmutableSet<Integer> getAttentionSetUserIds(ChangeData changeData) {
return additionsOnly(changeData.attentionSet()).stream()
.map(update -> update.account().get())
.collect(toImmutableSet());
}
private static ImmutableSet<byte[]> storedAttentionSet(ChangeData changeData) {
return changeData.attentionSet().stream()
.map(StoredAttentionSetEntry::new)
.map(storedAttentionSetEntry -> GSON.toJson(storedAttentionSetEntry).getBytes(UTF_8))
.collect(toImmutableSet());
}
/**
* Deserializes the specified attention set entries from JSON and stores them in the specified
* change.
*/
public static void parseAttentionSet(
Collection<String> storedAttentionSetEntriesJson, ChangeData changeData) {
ImmutableSet<AttentionSetUpdate> attentionSet =
storedAttentionSetEntriesJson.stream()
.map(
entry -> GSON.fromJson(entry, StoredAttentionSetEntry.class).toAttentionSetUpdate())
.collect(toImmutableSet());
changeData.setAttentionSet(attentionSet);
}
/** Commit ID of any patch set on the change, using prefix match. */
public static final IndexedField<ChangeData, Iterable<String>> COMMIT_FIELD =
IndexedField.<ChangeData>iterableStringBuilder("Commit")
.size(40)
.required()
.build(ChangeField::getRevisions);
public static final IndexedField<ChangeData, Iterable<String>>.SearchSpec COMMIT_SPEC =
COMMIT_FIELD.prefix(ChangeQueryBuilder.FIELD_COMMIT);
/** Commit ID of any patch set on the change, using exact match. */
public static final IndexedField<ChangeData, Iterable<String>>.SearchSpec EXACT_COMMIT_SPEC =
COMMIT_FIELD.exact(ChangeQueryBuilder.FIELD_EXACTCOMMIT);
private static ImmutableSet<String> getRevisions(ChangeData cd) {
return cd.patchSets().stream().map(ps -> ps.commitId().name()).collect(toImmutableSet());
}
/** Tracking id extracted from a footer. */
public static final IndexedField<ChangeData, Iterable<String>> TR_FIELD =
IndexedField.<ChangeData>iterableStringBuilder("TrackingFooter")
.build(cd -> ImmutableSet.copyOf(cd.trackingFooters().values()));
public static final IndexedField<ChangeData, Iterable<String>>.SearchSpec TR_SPEC =
TR_FIELD.exact(ChangeQueryBuilder.FIELD_TR);
/** List of labels on the current patch set including change owner votes. */
public static final IndexedField<ChangeData, Iterable<String>> LABEL_FIELD =
IndexedField.<ChangeData>iterableStringBuilder("Label").required().build(cd -> getLabels(cd));
public static final IndexedField<ChangeData, Iterable<String>>.SearchSpec LABEL_SPEC =
LABEL_FIELD.exact("label2");
private static Iterable<String> getLabels(ChangeData cd) {
Set<String> allApprovals = new HashSet<>();
Set<String> distinctApprovals = new HashSet<>();
Table<String, Short, Integer> voteCounts = HashBasedTable.create();
for (PatchSetApproval a : cd.currentApprovals()) {
if (a.value() != 0 && !a.isLegacySubmit()) {
increment(voteCounts, a.label(), a.value());
Optional<LabelType> labelType = cd.getLabelTypes().byLabel(a.labelId());
allApprovals.add(formatLabel(a.label(), a.value(), a.accountId()));
allApprovals.addAll(getMagicLabelFormats(a.label(), a.value(), labelType, a.accountId()));
allApprovals.addAll(getLabelOwnerFormats(a, cd, labelType));
allApprovals.addAll(getLabelNonUploaderFormats(a, cd, labelType));
distinctApprovals.add(formatLabel(a.label(), a.value()));
distinctApprovals.addAll(
getMagicLabelFormats(a.label(), a.value(), labelType, /* accountId= */ null));
}
}
allApprovals.addAll(distinctApprovals);
allApprovals.addAll(getCountLabelFormats(voteCounts, cd));
return allApprovals;
}
private static void increment(Table<String, Short, Integer> table, String k1, short k2) {
if (!table.contains(k1, k2)) {
table.put(k1, k2, 1);
} else {
int val = table.get(k1, k2);
table.put(k1, k2, val + 1);
}
}
private static List<String> getCountLabelFormats(
Table<String, Short, Integer> voteCounts, ChangeData cd) {
List<String> allFormats = new ArrayList<>();
for (String label : voteCounts.rowMap().keySet()) {
Optional<LabelType> labelType = cd.getLabelTypes().byLabel(label);
Map<Short, Integer> row = voteCounts.row(label);
for (short vote : row.keySet()) {
int count = row.get(vote);
allFormats.addAll(getCountLabelFormats(labelType, label, vote, count));
}
}
return allFormats;
}
private static List<String> getCountLabelFormats(
Optional<LabelType> labelType, String label, short vote, int count) {
List<String> formats =
getMagicLabelFormats(label, vote, labelType, /* accountId= */ null, /* count= */ count);
formats.add(formatLabel(label, vote, count));
return formats;
}
/** Get magic label formats corresponding to the {MIN, MAX, ANY} label votes. */
private static List<String> getMagicLabelFormats(
String label, short labelVal, Optional<LabelType> labelType, @Nullable Account.Id accountId) {
return getMagicLabelFormats(label, labelVal, labelType, accountId, /* count= */ null);
}
/** Get magic label formats corresponding to the {MIN, MAX, ANY} label votes. */
private static List<String> getMagicLabelFormats(
String label,
short labelVal,
Optional<LabelType> labelType,
@Nullable Account.Id accountId,
@Nullable Integer count) {
List<String> labels = new ArrayList<>();
if (labelType.isPresent()) {
if (labelVal == labelType.get().getMaxPositive()) {
labels.add(formatLabel(label, MagicLabelValue.MAX.name(), accountId, count));
}
if (labelVal == labelType.get().getMaxNegative()) {
labels.add(formatLabel(label, MagicLabelValue.MIN.name(), accountId, count));
}
}
labels.add(formatLabel(label, MagicLabelValue.ANY.name(), accountId, count));
return labels;
}
private static List<String> getLabelOwnerFormats(
PatchSetApproval a, ChangeData cd, Optional<LabelType> labelType) {
List<String> allFormats = new ArrayList<>();
if (cd.change().getOwner().equals(a.accountId())) {
allFormats.add(formatLabel(a.label(), a.value(), ChangeQueryBuilder.OWNER_ACCOUNT_ID));
allFormats.addAll(
getMagicLabelFormats(
a.label(), a.value(), labelType, ChangeQueryBuilder.OWNER_ACCOUNT_ID));
}
return allFormats;
}
private static List<String> getLabelNonUploaderFormats(
PatchSetApproval a, ChangeData cd, Optional<LabelType> labelType) {
List<String> allFormats = new ArrayList<>();
if (!cd.currentPatchSet().uploader().equals(a.accountId())) {
allFormats.add(formatLabel(a.label(), a.value(), ChangeQueryBuilder.NON_UPLOADER_ACCOUNT_ID));
allFormats.addAll(
getMagicLabelFormats(
a.label(), a.value(), labelType, ChangeQueryBuilder.NON_UPLOADER_ACCOUNT_ID));
}
return allFormats;
}
public static Set<String> getAuthorParts(ChangeData cd) {
return SchemaUtil.getPersonParts(cd.getAuthor());
}
public static Set<String> getAuthorNameAndEmail(ChangeData cd) {
return getNameAndEmail(cd.getAuthor());
}
public static Set<String> getCommitterParts(ChangeData cd) {
return SchemaUtil.getPersonParts(cd.getCommitter());
}
public static Set<String> getCommitterNameAndEmail(ChangeData cd) {
return getNameAndEmail(cd.getCommitter());
}
private static Set<String> getNameAndEmail(PersonIdent person) {
if (person == null) {
return ImmutableSet.of();
}
String name = person.getName().toLowerCase(Locale.US);
String email = person.getEmailAddress().toLowerCase(Locale.US);
StringBuilder nameEmailBuilder = new StringBuilder();
PersonIdent.appendSanitized(nameEmailBuilder, name);
nameEmailBuilder.append(" <");
PersonIdent.appendSanitized(nameEmailBuilder, email);
nameEmailBuilder.append('>');
return ImmutableSet.of(name, email, nameEmailBuilder.toString());
}
/**
* The exact email address, or any part of the author name or email address, in the current patch
* set.
*/
public static final IndexedField<ChangeData, Iterable<String>> AUTHOR_PARTS_FIELD =
IndexedField.<ChangeData>iterableStringBuilder("AuthorParts")
.required()
.description(
"The exact email address, or any part of the author name or email address, in the current patch set.")
.build(ChangeField::getAuthorParts);
public static final IndexedField<ChangeData, Iterable<String>>.SearchSpec AUTHOR_PARTS_SPEC =
AUTHOR_PARTS_FIELD.fullText(ChangeQueryBuilder.FIELD_AUTHOR);
/** The exact name, email address and NameEmail of the author. */
public static final IndexedField<ChangeData, Iterable<String>> EXACT_AUTHOR_FIELD =
IndexedField.<ChangeData>iterableStringBuilder("ExactAuthor")
.required()
.description("The exact name, email address and NameEmail of the author.")
.build(ChangeField::getAuthorNameAndEmail);
public static final IndexedField<ChangeData, Iterable<String>>.SearchSpec EXACT_AUTHOR_SPEC =
EXACT_AUTHOR_FIELD.exact(ChangeQueryBuilder.FIELD_EXACTAUTHOR);
/**
* The exact email address, or any part of the committer name or email address, in the current
* patch set.
*/
public static final IndexedField<ChangeData, Iterable<String>> COMMITTER_PARTS_FIELD =
IndexedField.<ChangeData>iterableStringBuilder("CommitterParts")
.description(
"The exact email address, or any part of the committer name or email address, in the current patch set.")
.required()
.build(ChangeField::getCommitterParts);
public static final IndexedField<ChangeData, Iterable<String>>.SearchSpec COMMITTER_PARTS_SPEC =
COMMITTER_PARTS_FIELD.fullText(ChangeQueryBuilder.FIELD_COMMITTER);
/** The exact name, email address, and NameEmail of the committer. */
public static final IndexedField<ChangeData, Iterable<String>> EXACT_COMMITTER_FIELD =
IndexedField.<ChangeData>iterableStringBuilder("ExactCommiter")
.required()
.description("The exact name, email address, and NameEmail of the committer.")
.build(ChangeField::getCommitterNameAndEmail);
public static final IndexedField<ChangeData, Iterable<String>>.SearchSpec EXACT_COMMITTER_SPEC =
EXACT_COMMITTER_FIELD.exact(ChangeQueryBuilder.FIELD_EXACTCOMMITTER);
/** Serialized change object, used for pre-populating results. */
public static final FieldDef<ChangeData, byte[]> CHANGE =
storedOnly("_change")
.build(
changeGetter(change -> toProto(ChangeProtoConverter.INSTANCE, change)),
(cd, field) -> cd.setChange(parseProtoFrom(field, ChangeProtoConverter.INSTANCE)));
/** Serialized approvals for the current patch set, used for pre-populating results. */
public static final FieldDef<ChangeData, Iterable<byte[]>> APPROVAL =
storedOnly("_approval")
.buildRepeatable(
cd -> toProtos(PatchSetApprovalProtoConverter.INSTANCE, cd.currentApprovals()),
(cd, field) ->
cd.setCurrentApprovals(
decodeProtos(field, PatchSetApprovalProtoConverter.INSTANCE)));
public static String formatLabel(String label, int value) {
return formatLabel(label, value, /* accountId= */ null, /* count= */ null);
}
public static String formatLabel(String label, int value, @Nullable Integer count) {
return formatLabel(label, value, /* accountId= */ null, count);
}
public static String formatLabel(String label, int value, Account.Id accountId) {
return formatLabel(label, value, accountId, /* count= */ null);
}
public static String formatLabel(
String label, int value, @Nullable Account.Id accountId, @Nullable Integer count) {
return label.toLowerCase()
+ (value >= 0 ? "+" : "")
+ value
+ (accountId != null ? "," + formatAccount(accountId) : "")
+ (count != null ? ",count=" + count : "");
}
public static String formatLabel(
String label, String value, @Nullable Account.Id accountId, @Nullable Integer count) {
return label.toLowerCase()
+ "="
+ value
+ (accountId != null ? "," + formatAccount(accountId) : "")
+ (count != null ? ",count=" + count : "");
}
private static String formatAccount(Account.Id accountId) {
if (ChangeQueryBuilder.OWNER_ACCOUNT_ID.equals(accountId)) {
return ChangeQueryBuilder.ARG_ID_OWNER;
} else if (ChangeQueryBuilder.NON_UPLOADER_ACCOUNT_ID.equals(accountId)) {
return ChangeQueryBuilder.ARG_ID_NON_UPLOADER;
}
return Integer.toString(accountId.get());
}
/** Commit message of the current patch set. */
public static final IndexedField<ChangeData, String> COMMIT_MESSAGE_FIELD =
IndexedField.<ChangeData>stringBuilder("CommitMessage")
.required()
.build(ChangeData::commitMessage);
public static final IndexedField<ChangeData, String>.SearchSpec COMMIT_MESSAGE =
COMMIT_MESSAGE_FIELD.fullText(ChangeQueryBuilder.FIELD_MESSAGE);
/** Commit message of the current patch set, used to exactly match the commit message */
public static final IndexedField<ChangeData, String> COMMIT_MESSAGE_EXACT_FIELD =
IndexedField.<ChangeData>stringBuilder("CommitMessageExact")
.required()
.description(
"Same as CommitMessage, but truncated, since supporting such large tokens may be problematic for indexes.")
.build(cd -> truncateStringValueToMaxTermLength(cd.commitMessage()));
public static final IndexedField<ChangeData, String>.SearchSpec COMMIT_MESSAGE_EXACT =
COMMIT_MESSAGE_EXACT_FIELD.exact(ChangeQueryBuilder.FIELD_MESSAGE_EXACT);
/** Summary or inline comment. */
public static final IndexedField<ChangeData, Iterable<String>> COMMENT_FIELD =
IndexedField.<ChangeData>iterableStringBuilder("Comment")
.build(
cd ->
Stream.concat(
cd.publishedComments().stream().map(c -> c.message),
// Some endpoint allow passing user message in input, and we still want to
// search by that. Index on message template with placeholders for user
// data, so we don't
// persist user identifiable information data in index.
cd.messages().stream().map(ChangeMessage::getMessage))
.collect(toSet()));
public static final IndexedField<ChangeData, Iterable<String>>.SearchSpec COMMENT_SPEC =
COMMENT_FIELD.fullText(ChangeQueryBuilder.FIELD_COMMENT);
/** Number of unresolved comment threads of the change, including robot comments. */
public static final IndexedField<ChangeData, Integer> UNRESOLVED_COMMENT_COUNT_FIELD =
IndexedField.<ChangeData>integerBuilder("UnresolvedCommentCount")
.stored()
.build(
ChangeData::unresolvedCommentCount,
(cd, field) -> cd.setUnresolvedCommentCount(field));
public static final IndexedField<ChangeData, Integer>.SearchSpec UNRESOLVED_COMMENT_COUNT_SPEC =
UNRESOLVED_COMMENT_COUNT_FIELD.integerRange(
ChangeQueryBuilder.FIELD_UNRESOLVED_COMMENT_COUNT);
/** Total number of published inline comments of the change, including robot comments. */
public static final IndexedField<ChangeData, Integer> TOTAL_COMMENT_COUNT_FIELD =
IndexedField.<ChangeData>integerBuilder("TotalCommentCount")
.stored()
.build(ChangeData::totalCommentCount, (cd, field) -> cd.setTotalCommentCount(field));
public static final IndexedField<ChangeData, Integer>.SearchSpec TOTAL_COMMENT_COUNT_SPEC =
TOTAL_COMMENT_COUNT_FIELD.integerRange("total_comments");
/** Whether the change is mergeable. */
public static final IndexedField<ChangeData, String> MERGEABLE_FIELD =
IndexedField.<ChangeData>stringBuilder("Mergeable")
.stored()
.size(1)
.build(
cd -> {
Boolean m = cd.isMergeable();
if (m == null) {
return null;
}
return m ? "1" : "0";
},
(cd, field) -> cd.setMergeable(field == null ? false : field.equals("1")));
public static final IndexedField<ChangeData, String>.SearchSpec MERGEABLE_SPEC =
MERGEABLE_FIELD.exact(ChangeQueryBuilder.FIELD_MERGEABLE);
/** Whether the change is a merge commit. */
public static final IndexedField<ChangeData, String> MERGE_FIELD =
IndexedField.<ChangeData>stringBuilder("Merge")
.stored()
.size(1)
.build(
cd -> {
Boolean m = cd.isMerge();
if (m == null) {
return null;
}
return m ? "1" : "0";
});
public static final IndexedField<ChangeData, String>.SearchSpec MERGE_SPEC =
MERGE_FIELD.exact(ChangeQueryBuilder.FIELD_MERGE);
/** Whether the change is a cherry pick of another change. */
public static final IndexedField<ChangeData, String> CHERRY_PICK_FIELD =
IndexedField.<ChangeData>stringBuilder("CherryPick")
.stored()
.size(1)
.build(cd -> cd.change().getCherryPickOf() != null ? "1" : "0");
public static final IndexedField<ChangeData, String>.SearchSpec CHERRY_PICK_SPEC =
CHERRY_PICK_FIELD.exact(ChangeQueryBuilder.FIELD_CHERRYPICK);
/** The number of inserted lines in this change. */
public static final IndexedField<ChangeData, Integer> ADDED_LINES_FIELD =
IndexedField.<ChangeData>integerBuilder("AddedLines")
.stored()
.build(
cd -> cd.changedLines().isPresent() ? cd.changedLines().get().insertions : null,
(cd, field) -> {
if (field != null) {
cd.setLinesInserted(field);
}
});
public static final IndexedField<ChangeData, Integer>.SearchSpec ADDED_LINES_SPEC =
ADDED_LINES_FIELD.integerRange(ChangeQueryBuilder.FIELD_ADDED);
/** The number of deleted lines in this change. */
public static final IndexedField<ChangeData, Integer> DELETED_LINES_FIELD =
IndexedField.<ChangeData>integerBuilder("DeletedLines")
.stored()
.build(
cd -> cd.changedLines().isPresent() ? cd.changedLines().get().deletions : null,
(cd, field) -> {
if (field != null) {
cd.setLinesDeleted(field);
}
});
public static final IndexedField<ChangeData, Integer>.SearchSpec DELETED_LINES_SPEC =
DELETED_LINES_FIELD.integerRange(ChangeQueryBuilder.FIELD_DELETED);
/** The total number of modified lines in this change. */
public static final IndexedField<ChangeData, Integer> DELTA_LINES_FIELD =
IndexedField.<ChangeData>integerBuilder("DeltaLines")
.stored()
.build(cd -> cd.changedLines().map(c -> c.insertions + c.deletions).orElse(null));
public static final IndexedField<ChangeData, Integer>.SearchSpec DELTA_LINES_SPEC =
DELTA_LINES_FIELD.integerRange(ChangeQueryBuilder.FIELD_DELTA);
/** Determines if this change is private. */
public static final IndexedField<ChangeData, String> PRIVATE_FIELD =
IndexedField.<ChangeData>stringBuilder("Private")
.size(1)
.build(cd -> cd.change().isPrivate() ? "1" : "0");
public static final IndexedField<ChangeData, String>.SearchSpec PRIVATE_SPEC =
PRIVATE_FIELD.exact(ChangeQueryBuilder.FIELD_PRIVATE);
/** Determines if this change is work in progress. */
public static final IndexedField<ChangeData, String> WIP_FIELD =
IndexedField.<ChangeData>stringBuilder("WIP")
.size(1)
.build(cd -> cd.change().isWorkInProgress() ? "1" : "0");
public static final IndexedField<ChangeData, String>.SearchSpec WIP_SPEC =
WIP_FIELD.exact(ChangeQueryBuilder.FIELD_WIP);
/** Determines if this change has started review. */
public static final IndexedField<ChangeData, String> STARTED_FIELD =
IndexedField.<ChangeData>stringBuilder("ReviewStarted")
.size(1)
.build(cd -> cd.change().hasReviewStarted() ? "1" : "0");
public static final IndexedField<ChangeData, String>.SearchSpec STARTED_SPEC =
STARTED_FIELD.exact(ChangeQueryBuilder.FIELD_STARTED);
/** Users who have commented on this change. */
public static final IndexedField<ChangeData, Iterable<Integer>> COMMENTBY_FIELD =
IndexedField.<ChangeData>iterableIntegerBuilder("CommentBy")
.build(
cd ->
Stream.concat(
cd.messages().stream().map(ChangeMessage::getAuthor),
cd.publishedComments().stream().map(c -> c.author.getId()))
.filter(Objects::nonNull)
.map(Account.Id::get)
.collect(toSet()));
public static final IndexedField<ChangeData, Iterable<Integer>>.SearchSpec COMMENTBY_SPEC =
COMMENTBY_FIELD.integer(ChangeQueryBuilder.FIELD_COMMENTBY);
/** Star labels on this change in the format: &lt;account-id&gt;:&lt;label&gt; */
public static final FieldDef<ChangeData, Iterable<String>> STAR =
exact(ChangeQueryBuilder.FIELD_STAR)
.stored()
.buildRepeatable(
cd ->
Iterables.transform(
cd.stars().entries(),
e ->
StarredChangesUtil.StarField.create(e.getKey(), e.getValue()).toString()),
(cd, field) ->
cd.setStars(
StreamSupport.stream(field.spliterator(), false)
.map(f -> StarredChangesUtil.StarField.parse(f))
.collect(toImmutableListMultimap(e -> e.accountId(), e -> e.label()))));
/** Users that have starred the change with any label. */
public static final FieldDef<ChangeData, Iterable<Integer>> STARBY =
integer(ChangeQueryBuilder.FIELD_STARBY)
.buildRepeatable(cd -> Iterables.transform(cd.stars().keySet(), Account.Id::get));
/** Opaque group identifiers for this change's patch sets. */
public static final FieldDef<ChangeData, Iterable<String>> GROUP =
exact(ChangeQueryBuilder.FIELD_GROUP)
.buildRepeatable(
cd -> cd.patchSets().stream().flatMap(ps -> ps.groups().stream()).collect(toSet()));
/** Serialized patch set object, used for pre-populating results. */
public static final FieldDef<ChangeData, Iterable<byte[]>> PATCH_SET =
storedOnly("_patch_set")
.buildRepeatable(
cd -> toProtos(PatchSetProtoConverter.INSTANCE, cd.patchSets()),
(cd, field) -> cd.setPatchSets(decodeProtos(field, PatchSetProtoConverter.INSTANCE)));
/** Users who have edits on this change. */
public static final FieldDef<ChangeData, Iterable<Integer>> EDITBY =
integer(ChangeQueryBuilder.FIELD_EDITBY)
.buildRepeatable(cd -> cd.editsByUser().stream().map(Account.Id::get).collect(toSet()));
/** Users who have draft comments on this change. */
public static final FieldDef<ChangeData, Iterable<Integer>> DRAFTBY =
integer(ChangeQueryBuilder.FIELD_DRAFTBY)
.buildRepeatable(cd -> cd.draftsByUser().stream().map(Account.Id::get).collect(toSet()));
public static final Integer NOT_REVIEWED = -1;
/**
* Users the change was reviewed by since the last author update.
*
* <p>A change is considered reviewed by a user if the latest update by that user is newer than
* the latest update by the change author. Both top-level change messages and new patch sets are
* considered to be updates.
*
* <p>If the latest update is by the change owner, then the special value {@link #NOT_REVIEWED} is
* emitted.
*/
public static final FieldDef<ChangeData, Iterable<Integer>> REVIEWEDBY =
integer(ChangeQueryBuilder.FIELD_REVIEWEDBY)
.stored()
.buildRepeatable(
cd -> {
Set<Account.Id> reviewedBy = cd.reviewedBy();
if (reviewedBy.isEmpty()) {
return ImmutableSet.of(NOT_REVIEWED);
}
return reviewedBy.stream().map(Account.Id::get).collect(toList());
},
(cd, field) ->
cd.setReviewedBy(
StreamSupport.stream(field.spliterator(), false)
.map(Account::id)
.collect(toImmutableSet())));
public static final SubmitRuleOptions SUBMIT_RULE_OPTIONS_LENIENT =
SubmitRuleOptions.builder().recomputeOnClosedChanges(true).build();
public static final SubmitRuleOptions SUBMIT_RULE_OPTIONS_STRICT =
SubmitRuleOptions.builder().build();
/** All submit rules results in the form of "$ruleName,$status". */
public static final FieldDef<ChangeData, Iterable<String>> SUBMIT_RULE_RESULT =
exact("submit_rule_result")
.buildRepeatable(
cd -> {
List<String> result = new ArrayList<>();
List<SubmitRecord> submitRecords = cd.submitRecords(SUBMIT_RULE_OPTIONS_STRICT);
for (SubmitRecord record : submitRecords) {
result.add(record.ruleName + "=" + record.status.name());
}
return result;
});
/**
* JSON type for storing SubmitRecords.
*
* <p>Stored fields need to use a stable format over a long period; this type insulates the index
* from implementation changes in SubmitRecord itself.
*/
public static class StoredSubmitRecord {
static class StoredLabel {
String label;
SubmitRecord.Label.Status status;
Integer appliedBy;
}
static class StoredRequirement {
String fallbackText;
String type;
@Deprecated Map<String, String> data;
}
String ruleName;
SubmitRecord.Status status;
List<StoredLabel> labels;
List<StoredRequirement> requirements;
String errorMessage;
public StoredSubmitRecord(SubmitRecord rec) {
this.ruleName = rec.ruleName;
this.status = rec.status;
this.errorMessage = rec.errorMessage;
if (rec.labels != null) {
this.labels = new ArrayList<>(rec.labels.size());
for (SubmitRecord.Label label : rec.labels) {
StoredLabel sl = new StoredLabel();
sl.label = label.label;
sl.status = label.status;
sl.appliedBy = label.appliedBy != null ? label.appliedBy.get() : null;
this.labels.add(sl);
}
}
if (rec.requirements != null) {
this.requirements = new ArrayList<>(rec.requirements.size());
for (LegacySubmitRequirement requirement : rec.requirements) {
StoredRequirement sr = new StoredRequirement();
sr.type = requirement.type();
sr.fallbackText = requirement.fallbackText();
// For backwards compatibility, write an empty map to the index.
// This is required, because the LegacySubmitRequirement AutoValue can't
// handle null in the old code.
// TODO(hiesel): Remove once we have rolled out the new code
// and waited long enough to not need to roll back.
sr.data = ImmutableMap.of();
this.requirements.add(sr);
}
}
}
public SubmitRecord toSubmitRecord() {
SubmitRecord rec = new SubmitRecord();
rec.ruleName = ruleName;
rec.status = status;
rec.errorMessage = errorMessage;
if (labels != null) {
rec.labels = new ArrayList<>(labels.size());
for (StoredLabel label : labels) {
SubmitRecord.Label srl = new SubmitRecord.Label();
srl.label = label.label;
srl.status = label.status;
srl.appliedBy = label.appliedBy != null ? Account.id(label.appliedBy) : null;
rec.labels.add(srl);
}
}
if (requirements != null) {
rec.requirements = new ArrayList<>(requirements.size());
for (StoredRequirement req : requirements) {
LegacySubmitRequirement sr =
LegacySubmitRequirement.builder()
.setType(req.type)
.setFallbackText(req.fallbackText)
.build();
rec.requirements.add(sr);
}
}
return rec;
}
}
public static final FieldDef<ChangeData, Iterable<String>> SUBMIT_RECORD =
exact("submit_record").buildRepeatable(ChangeField::formatSubmitRecordValues);
public static final FieldDef<ChangeData, Iterable<byte[]>> STORED_SUBMIT_RECORD_STRICT =
storedOnly("full_submit_record_strict")
.buildRepeatable(
cd -> storedSubmitRecords(cd, SUBMIT_RULE_OPTIONS_STRICT),
(cd, field) ->
parseSubmitRecords(
StreamSupport.stream(field.spliterator(), false)
.map(f -> new String(f, UTF_8))
.collect(toSet()),
SUBMIT_RULE_OPTIONS_STRICT,
cd));
public static final FieldDef<ChangeData, Iterable<byte[]>> STORED_SUBMIT_RECORD_LENIENT =
storedOnly("full_submit_record_lenient")
.buildRepeatable(
cd -> storedSubmitRecords(cd, SUBMIT_RULE_OPTIONS_LENIENT),
(cd, field) ->
parseSubmitRecords(
StreamSupport.stream(field.spliterator(), false)
.map(f -> new String(f, UTF_8))
.collect(toSet()),
SUBMIT_RULE_OPTIONS_LENIENT,
cd));
public static void parseSubmitRecords(
Collection<String> values, SubmitRuleOptions opts, ChangeData out) {
List<SubmitRecord> records = parseSubmitRecords(values);
out.setSubmitRecords(opts, records);
}
@VisibleForTesting
static List<SubmitRecord> parseSubmitRecords(Collection<String> values) {
return values.stream()
.map(v -> GSON.fromJson(v, StoredSubmitRecord.class).toSubmitRecord())
.collect(toList());
}
@VisibleForTesting
static List<byte[]> storedSubmitRecords(List<SubmitRecord> records) {
return Lists.transform(records, r -> GSON.toJson(new StoredSubmitRecord(r)).getBytes(UTF_8));
}
private static Iterable<byte[]> storedSubmitRecords(ChangeData cd, SubmitRuleOptions opts) {
return storedSubmitRecords(cd.submitRecords(opts));
}
public static List<String> formatSubmitRecordValues(ChangeData cd) {
Set<String> submitRecordValues = new HashSet<>();
submitRecordValues.addAll(
formatSubmitRecordValues(
cd.submitRecords(SUBMIT_RULE_OPTIONS_STRICT), cd.change().getOwner()));
// Also backfill results of submit requirements such that users can query submit requirement
// results using the label operator, for example a query with "label:CR=NEED" will match with
// changes that have a submit-requirement with name="CR" and status=UNSATISFIED.
// Reason: We are preserving backward compatibility of the operators `label:$name=$status`
// which were previously working with submit records. Now admins can configure submit
// requirements and continue querying them with the label operator.
submitRecordValues.addAll(formatSubmitRequirementValues(cd.submitRequirements().values()));
return submitRecordValues.stream().collect(Collectors.toList());
}
@VisibleForTesting
static List<String> formatSubmitRecordValues(List<SubmitRecord> records, Account.Id changeOwner) {
List<String> result = new ArrayList<>();
for (SubmitRecord rec : records) {
result.add(rec.status.name());
if (rec.labels == null) {
continue;
}
for (SubmitRecord.Label label : rec.labels) {
String sl = label.status.toString() + ',' + label.label.toLowerCase();
result.add(sl);
String slc = sl + ',';
if (label.appliedBy != null) {
result.add(slc + label.appliedBy.get());
if (label.appliedBy.equals(changeOwner)) {
result.add(slc + ChangeQueryBuilder.OWNER_ACCOUNT_ID.get());
}
}
}
}
return result;
}
/**
* Generate submit requirement result formats that are compatible with the legacy submit record
* statuses.
*/
@VisibleForTesting
static List<String> formatSubmitRequirementValues(Collection<SubmitRequirementResult> srResults) {
List<String> result = new ArrayList<>();
for (SubmitRequirementResult srResult : srResults) {
switch (srResult.status()) {
case SATISFIED:
case OVERRIDDEN:
case FORCED:
result.add(
SubmitRecord.Label.Status.OK.name()
+ ","
+ srResult.submitRequirement().name().toLowerCase());
result.add(
SubmitRecord.Label.Status.MAY.name()
+ ","
+ srResult.submitRequirement().name().toLowerCase());
break;
case UNSATISFIED:
result.add(
SubmitRecord.Label.Status.NEED.name()
+ ","
+ srResult.submitRequirement().name().toLowerCase());
result.add(
SubmitRecord.Label.Status.REJECT.name()
+ ","
+ srResult.submitRequirement().name().toLowerCase());
break;
case NOT_APPLICABLE:
case ERROR:
result.add(
SubmitRecord.Label.Status.IMPOSSIBLE.name()
+ ","
+ srResult.submitRequirement().name().toLowerCase());
}
}
return result;
}
/** Serialized submit requirements, used for pre-populating results. */
public static final FieldDef<ChangeData, Iterable<byte[]>> STORED_SUBMIT_REQUIREMENTS =
storedOnly("full_submit_requirements")
.buildRepeatable(
cd ->
toProtos(
SubmitRequirementProtoConverter.INSTANCE, cd.submitRequirements().values()),
(cd, field) -> parseSubmitRequirements(field, cd));
private static void parseSubmitRequirements(Iterable<byte[]> values, ChangeData out) {
out.setSubmitRequirements(
StreamSupport.stream(values.spliterator(), false)
.map(
f ->
SubmitRequirementProtoConverter.INSTANCE.fromProto(
Protos.parseUnchecked(
SubmitRequirementProtoConverter.INSTANCE.getParser(), f)))
.filter(sr -> !sr.isLegacy())
.collect(
ImmutableMap.toImmutableMap(sr -> sr.submitRequirement(), Function.identity())));
}
/**
* All values of all refs that were used in the course of indexing this document.
*
* <p>Emitted as UTF-8 encoded strings of the form {@code project:ref/name:[hex sha]}.
*/
public static final FieldDef<ChangeData, Iterable<byte[]>> REF_STATE =
storedOnly("ref_state")
.buildRepeatable(
cd -> {
List<byte[]> result = new ArrayList<>();
cd.getRefStates()
.entries()
.forEach(e -> result.add(e.getValue().toByteArray(e.getKey())));
return result;
},
(cd, field) -> cd.setRefStates(RefState.parseStates(field)));
/**
* All ref wildcard patterns that were used in the course of indexing this document.
*
* <p>Emitted as UTF-8 encoded strings of the form {@code project:ref/name/*}. See {@link
* RefStatePattern} for the pattern format.
*/
public static final FieldDef<ChangeData, Iterable<byte[]>> REF_STATE_PATTERN =
storedOnly("ref_state_pattern")
.buildRepeatable(
cd -> {
Change.Id id = cd.getId();
Project.NameKey project = cd.change().getProject();
List<byte[]> result = new ArrayList<>(3);
result.add(
RefStatePattern.create(
RefNames.REFS_USERS + "*/" + RefNames.EDIT_PREFIX + id + "/*")
.toByteArray(project));
result.add(
RefStatePattern.create(RefNames.refsStarredChangesPrefix(id) + "*")
.toByteArray(allUsers(cd)));
result.add(
RefStatePattern.create(RefNames.refsDraftCommentsPrefix(id) + "*")
.toByteArray(allUsers(cd)));
return result;
},
(cd, field) -> cd.setRefStatePatterns(field));
@Nullable
private static String getTopic(ChangeData cd) {
Change c = cd.change();
if (c == null) {
return null;
}
return firstNonNull(c.getTopic(), "");
}
private static <T> List<byte[]> toProtos(ProtoConverter<?, T> converter, Collection<T> objects) {
return objects.stream().map(object -> toProto(converter, object)).collect(toImmutableList());
}
private static <T> byte[] toProto(ProtoConverter<?, T> converter, T object) {
return Protos.toByteArray(converter.toProto(object));
}
private static <T> List<T> decodeProtos(Iterable<byte[]> raw, ProtoConverter<?, T> converter) {
return StreamSupport.stream(raw.spliterator(), false)
.map(bytes -> parseProtoFrom(bytes, converter))
.collect(toImmutableList());
}
private static <P extends MessageLite, T> T parseProtoFrom(
byte[] bytes, ProtoConverter<P, T> converter) {
P message = Protos.parseUnchecked(converter.getParser(), bytes, 0, bytes.length);
return converter.fromProto(message);
}
private static <T> SchemaFieldDefs.Getter<ChangeData, T> changeGetter(Function<Change, T> func) {
return in -> in.change() != null ? func.apply(in.change()) : null;
}
private static AllUsersName allUsers(ChangeData cd) {
return cd.getAllUsersNameForIndexing();
}
private static String truncateStringValueToMaxTermLength(String str) {
return truncateStringValue(str, MAX_TERM_LENGTH);
}
@VisibleForTesting
static String truncateStringValue(String str, int maxBytes) {
if (maxBytes < 0) {
throw new IllegalArgumentException("maxBytes < 0 not allowed");
}
if (maxBytes == 0) {
return "";
}
if (str.length() > maxBytes) {
if (Character.isHighSurrogate(str.charAt(maxBytes - 1))) {
str = str.substring(0, maxBytes - 1);
} else {
str = str.substring(0, maxBytes);
}
}
byte[] strBytes = str.getBytes(UTF_8);
if (strBytes.length > maxBytes) {
while (maxBytes > 0 && (strBytes[maxBytes] & 0xC0) == 0x80) {
maxBytes -= 1;
}
if (maxBytes > 0) {
if (strBytes.length >= maxBytes && (strBytes[maxBytes - 1] & 0xE0) == 0xC0) {
maxBytes -= 1;
}
if (strBytes.length >= maxBytes && (strBytes[maxBytes - 1] & 0xF0) == 0xE0) {
maxBytes -= 1;
}
if (strBytes.length >= maxBytes && (strBytes[maxBytes - 1] & 0xF8) == 0xF0) {
maxBytes -= 1;
}
}
return new String(Arrays.copyOfRange(strBytes, 0, maxBytes), UTF_8);
}
return str;
}
}