| // 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.gerrit.index.FieldDef.exact; |
| import static com.google.gerrit.index.FieldDef.fullText; |
| import static com.google.gerrit.index.FieldDef.intRange; |
| 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 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.Enums; |
| import com.google.common.base.Splitter; |
| import com.google.common.collect.ImmutableList; |
| 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.data.SubmitRecord; |
| import com.google.gerrit.common.data.SubmitRequirement; |
| import com.google.gerrit.index.FieldDef; |
| import com.google.gerrit.index.RefState; |
| import com.google.gerrit.index.SchemaUtil; |
| import com.google.gerrit.json.OutputFormat; |
| import com.google.gerrit.mail.Address; |
| import com.google.gerrit.proto.Protos; |
| import com.google.gerrit.reviewdb.client.Account; |
| import com.google.gerrit.reviewdb.client.Change; |
| import com.google.gerrit.reviewdb.client.ChangeMessage; |
| import com.google.gerrit.reviewdb.client.PatchSet; |
| import com.google.gerrit.reviewdb.client.PatchSetApproval; |
| import com.google.gerrit.reviewdb.client.Project; |
| import com.google.gerrit.reviewdb.client.RefNames; |
| import com.google.gerrit.reviewdb.converter.ChangeProtoConverter; |
| import com.google.gerrit.reviewdb.converter.PatchSetApprovalProtoConverter; |
| import com.google.gerrit.reviewdb.converter.PatchSetProtoConverter; |
| import com.google.gerrit.reviewdb.converter.ProtoConverter; |
| 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.ChangeNotes; |
| import com.google.gerrit.server.notedb.ReviewerStateInternal; |
| import com.google.gerrit.server.notedb.RobotCommentNotes; |
| 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.gson.Gson; |
| import java.sql.Timestamp; |
| import java.util.ArrayList; |
| 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.Stream; |
| 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. |
| */ |
| 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(); |
| |
| /** Legacy change ID. */ |
| public static final FieldDef<ChangeData, Integer> LEGACY_ID = |
| integer("legacy_id").stored().build(cd -> 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 FieldDef<ChangeData, String> STATUS = |
| exact(ChangeQueryBuilder.FIELD_STATUS) |
| .build(changeGetter(c -> ChangeStatusPredicate.canonicalize(c.getStatus()))); |
| |
| /** Project containing the change. */ |
| public static final FieldDef<ChangeData, String> PROJECT = |
| exact(ChangeQueryBuilder.FIELD_PROJECT) |
| .stored() |
| .build(changeGetter(c -> c.getProject().get())); |
| |
| /** Project containing the change, as a prefix field. */ |
| public static final FieldDef<ChangeData, String> PROJECTS = |
| prefix(ChangeQueryBuilder.FIELD_PROJECTS).build(changeGetter(c -> c.getProject().get())); |
| |
| /** Reference (aka branch) the change will submit onto. */ |
| public static final FieldDef<ChangeData, String> REF = |
| exact(ChangeQueryBuilder.FIELD_REF).build(changeGetter(c -> c.getDest().get())); |
| |
| /** Topic, a short annotation on the branch. */ |
| public static final FieldDef<ChangeData, String> EXACT_TOPIC = |
| exact("topic4").build(ChangeField::getTopic); |
| |
| /** Topic, a short annotation on the branch. */ |
| public static final FieldDef<ChangeData, String> FUZZY_TOPIC = |
| fullText("topic5").build(ChangeField::getTopic); |
| |
| /** Submission id assigned by MergeOp. */ |
| public static final FieldDef<ChangeData, String> SUBMISSIONID = |
| exact(ChangeQueryBuilder.FIELD_SUBMISSIONID).build(changeGetter(Change::getSubmissionId)); |
| |
| /** Last update time since January 1, 1970. */ |
| public static final FieldDef<ChangeData, Timestamp> UPDATED = |
| timestamp("updated2").stored().build(changeGetter(Change::getLastUpdatedOn)); |
| |
| /** List of full file paths modified in the current patch set. */ |
| public static final FieldDef<ChangeData, Iterable<String>> PATH = |
| // Named for backwards compatibility. |
| exact(ChangeQueryBuilder.FIELD_FILE) |
| .buildRepeatable(cd -> firstNonNull(cd.currentFilePaths(), ImmutableList.of())); |
| |
| 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 FieldDef<ChangeData, Iterable<String>> HASHTAG = |
| exact(ChangeQueryBuilder.FIELD_HASHTAG) |
| .buildRepeatable(cd -> cd.hashtags().stream().map(String::toLowerCase).collect(toSet())); |
| |
| /** Hashtags with original case. */ |
| public static final FieldDef<ChangeData, Iterable<byte[]>> HASHTAG_CASE_AWARE = |
| storedOnly("_hashtag") |
| .buildRepeatable( |
| cd -> cd.hashtags().stream().map(t -> t.getBytes(UTF_8)).collect(toSet())); |
| |
| /** Components of each file path modified in the current patch set. */ |
| public static final FieldDef<ChangeData, Iterable<String>> FILE_PART = |
| exact(ChangeQueryBuilder.FIELD_FILEPART).buildRepeatable(ChangeField::getFileParts); |
| |
| /** File extensions of each file modified in the current patch set. */ |
| public static final FieldDef<ChangeData, Iterable<String>> EXTENSION = |
| exact(ChangeQueryBuilder.FIELD_EXTENSION).buildRepeatable(ChangeField::getExtensions); |
| |
| 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 FieldDef<ChangeData, String> ONLY_EXTENSIONS = |
| exact(ChangeQueryBuilder.FIELD_ONLY_EXTENSIONS).build(ChangeField::getAllExtensionsAsList); |
| |
| 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 FieldDef<ChangeData, Iterable<String>> FOOTER = |
| exact(ChangeQueryBuilder.FIELD_FOOTER).buildRepeatable(ChangeField::getFooters); |
| |
| public static Set<String> getFooters(ChangeData cd) { |
| return cd.commitFooters().stream() |
| .map(f -> f.toString().toLowerCase(Locale.US)) |
| .collect(toSet()); |
| } |
| |
| /** Folders that are touched by the current patch set. */ |
| public static final FieldDef<ChangeData, Iterable<String>> DIRECTORY = |
| exact(ChangeQueryBuilder.FIELD_DIRECTORY).buildRepeatable(ChangeField::getDirectories); |
| |
| 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(); |
| directory.append(""); |
| r.add(directory.toString()); |
| String nextPart = null; |
| for (String part : s.split(path)) { |
| 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 FieldDef<ChangeData, Integer> OWNER = |
| integer(ChangeQueryBuilder.FIELD_OWNER).build(changeGetter(c -> c.getOwner().get())); |
| |
| /** The user assigned to the change. */ |
| public static final FieldDef<ChangeData, Integer> ASSIGNEE = |
| integer(ChangeQueryBuilder.FIELD_ASSIGNEE) |
| .build(changeGetter(c -> c.getAssignee() != null ? c.getAssignee().get() : NO_ASSIGNEE)); |
| |
| /** Reviewer(s) associated with the change. */ |
| public static final FieldDef<ChangeData, Iterable<String>> REVIEWER = |
| exact("reviewer2").stored().buildRepeatable(cd -> getReviewerFieldValues(cd.reviewers())); |
| |
| /** Reviewer(s) associated with the change that do not have a gerrit account. */ |
| public static final FieldDef<ChangeData, Iterable<String>> REVIEWER_BY_EMAIL = |
| exact("reviewer_by_email") |
| .stored() |
| .buildRepeatable(cd -> getReviewerByEmailFieldValues(cd.reviewersByEmail())); |
| |
| /** Reviewer(s) modified during change's current WIP phase. */ |
| public static final FieldDef<ChangeData, Iterable<String>> PENDING_REVIEWER = |
| exact(ChangeQueryBuilder.FIELD_PENDING_REVIEWER) |
| .stored() |
| .buildRepeatable(cd -> getReviewerFieldValues(cd.pendingReviewers())); |
| |
| /** Reviewer(s) by email modified during change's current WIP phase. */ |
| public static final FieldDef<ChangeData, Iterable<String>> PENDING_REVIEWER_BY_EMAIL = |
| exact(ChangeQueryBuilder.FIELD_PENDING_REVIEWER_BY_EMAIL) |
| .stored() |
| .buildRepeatable(cd -> getReviewerByEmailFieldValues(cd.pendingReviewersByEmail())); |
| |
| /** References a change that this change reverts. */ |
| public static final FieldDef<ChangeData, Integer> REVERT_OF = |
| integer(ChangeQueryBuilder.FIELD_REVERTOF) |
| .build(cd -> cd.change().getRevertOf() != null ? cd.change().getRevertOf().get() : null); |
| |
| @VisibleForTesting |
| static List<String> getReviewerFieldValues(ReviewerSet reviewers) { |
| List<String> r = new ArrayList<>(reviewers.asTable().size() * 2); |
| for (Table.Cell<ReviewerStateInternal, Account.Id, Timestamp> c : |
| reviewers.asTable().cellSet()) { |
| String v = getReviewerFieldValue(c.getRowKey(), c.getColumnKey()); |
| r.add(v); |
| r.add(v + ',' + c.getValue().getTime()); |
| } |
| 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, Timestamp> c : |
| reviewersByEmail.asTable().cellSet()) { |
| String v = getReviewerByEmailFieldValue(c.getRowKey(), c.getColumnKey()); |
| r.add(v); |
| if (c.getColumnKey().getName() != null) { |
| // Add another entry without the name to provide search functionality on the email |
| Address emailOnly = new Address(c.getColumnKey().getEmail()); |
| r.add(getReviewerByEmailFieldValue(c.getRowKey(), emailOnly)); |
| } |
| r.add(v + ',' + c.getValue().getTime()); |
| } |
| 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, Timestamp> 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; |
| } |
| |
| com.google.common.base.Optional<ReviewerStateInternal> reviewerState = |
| Enums.getIfPresent(ReviewerStateInternal.class, 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; |
| } |
| Timestamp timestamp = new Timestamp(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, Timestamp> 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; |
| } |
| |
| com.google.common.base.Optional<ReviewerStateInternal> reviewerState = |
| Enums.getIfPresent(ReviewerStateInternal.class, 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; |
| } |
| Timestamp timestamp = new Timestamp(l); |
| |
| b.put(reviewerState.get(), address, timestamp); |
| } |
| return ReviewerByEmailSet.fromTable(b.build()); |
| } |
| |
| /** Commit ID of any patch set on the change, using prefix match. */ |
| public static final FieldDef<ChangeData, Iterable<String>> COMMIT = |
| prefix(ChangeQueryBuilder.FIELD_COMMIT).buildRepeatable(ChangeField::getRevisions); |
| |
| /** Commit ID of any patch set on the change, using exact match. */ |
| public static final FieldDef<ChangeData, Iterable<String>> EXACT_COMMIT = |
| exact(ChangeQueryBuilder.FIELD_EXACTCOMMIT).buildRepeatable(ChangeField::getRevisions); |
| |
| private static Set<String> getRevisions(ChangeData cd) { |
| Set<String> revisions = new HashSet<>(); |
| for (PatchSet ps : cd.patchSets()) { |
| if (ps.getRevision() != null) { |
| revisions.add(ps.getRevision().get()); |
| } |
| } |
| return revisions; |
| } |
| |
| /** Tracking id extracted from a footer. */ |
| public static final FieldDef<ChangeData, Iterable<String>> TR = |
| exact(ChangeQueryBuilder.FIELD_TR) |
| .buildRepeatable(cd -> ImmutableSet.copyOf(cd.trackingFooters().values())); |
| |
| /** List of labels on the current patch set including change owner votes. */ |
| public static final FieldDef<ChangeData, Iterable<String>> LABEL = |
| exact("label2").buildRepeatable(cd -> getLabels(cd, true)); |
| |
| private static Iterable<String> getLabels(ChangeData cd, boolean owners) { |
| Set<String> allApprovals = new HashSet<>(); |
| Set<String> distinctApprovals = new HashSet<>(); |
| for (PatchSetApproval a : cd.currentApprovals()) { |
| if (a.getValue() != 0 && !a.isLegacySubmit()) { |
| allApprovals.add(formatLabel(a.getLabel(), a.getValue(), a.getAccountId())); |
| if (owners && cd.change().getOwner().equals(a.getAccountId())) { |
| allApprovals.add( |
| formatLabel(a.getLabel(), a.getValue(), ChangeQueryBuilder.OWNER_ACCOUNT_ID)); |
| } |
| distinctApprovals.add(formatLabel(a.getLabel(), a.getValue())); |
| } |
| } |
| allApprovals.addAll(distinctApprovals); |
| return allApprovals; |
| } |
| |
| 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 FieldDef<ChangeData, Iterable<String>> AUTHOR = |
| fullText(ChangeQueryBuilder.FIELD_AUTHOR).buildRepeatable(ChangeField::getAuthorParts); |
| |
| /** The exact name, email address and NameEmail of the author. */ |
| public static final FieldDef<ChangeData, Iterable<String>> EXACT_AUTHOR = |
| exact(ChangeQueryBuilder.FIELD_EXACTAUTHOR) |
| .buildRepeatable(ChangeField::getAuthorNameAndEmail); |
| |
| /** |
| * The exact email address, or any part of the committer name or email address, in the current |
| * patch set. |
| */ |
| public static final FieldDef<ChangeData, Iterable<String>> COMMITTER = |
| fullText(ChangeQueryBuilder.FIELD_COMMITTER).buildRepeatable(ChangeField::getCommitterParts); |
| |
| /** The exact name, email address, and NameEmail of the committer. */ |
| public static final FieldDef<ChangeData, Iterable<String>> EXACT_COMMITTER = |
| exact(ChangeQueryBuilder.FIELD_EXACTCOMMITTER) |
| .buildRepeatable(ChangeField::getCommitterNameAndEmail); |
| |
| /** Serialized change object, used for pre-populating results. */ |
| public static final FieldDef<ChangeData, byte[]> CHANGE = |
| storedOnly("_change") |
| .build(changeGetter(change -> toProto(ChangeProtoConverter.INSTANCE, change))); |
| |
| /** 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())); |
| |
| public static String formatLabel(String label, int value) { |
| return formatLabel(label, value, null); |
| } |
| |
| public static String formatLabel(String label, int value, Account.Id accountId) { |
| return label.toLowerCase() |
| + (value >= 0 ? "+" : "") |
| + value |
| + (accountId != null ? "," + formatAccount(accountId) : ""); |
| } |
| |
| private static String formatAccount(Account.Id accountId) { |
| if (ChangeQueryBuilder.OWNER_ACCOUNT_ID.equals(accountId)) { |
| return ChangeQueryBuilder.ARG_ID_OWNER; |
| } |
| return Integer.toString(accountId.get()); |
| } |
| |
| /** Commit message of the current patch set. */ |
| public static final FieldDef<ChangeData, String> COMMIT_MESSAGE = |
| fullText(ChangeQueryBuilder.FIELD_MESSAGE).build(ChangeData::commitMessage); |
| |
| /** Summary or inline comment. */ |
| public static final FieldDef<ChangeData, Iterable<String>> COMMENT = |
| fullText(ChangeQueryBuilder.FIELD_COMMENT) |
| .buildRepeatable( |
| cd -> |
| Stream.concat( |
| cd.publishedComments().stream().map(c -> c.message), |
| cd.messages().stream().map(ChangeMessage::getMessage)) |
| .collect(toSet())); |
| |
| /** Number of unresolved comment threads of the change, including robot comments. */ |
| public static final FieldDef<ChangeData, Integer> UNRESOLVED_COMMENT_COUNT = |
| intRange(ChangeQueryBuilder.FIELD_UNRESOLVED_COMMENT_COUNT) |
| .build(ChangeData::unresolvedCommentCount); |
| |
| /** Total number of published inline comments of the change, including robot comments. */ |
| public static final FieldDef<ChangeData, Integer> TOTAL_COMMENT_COUNT = |
| intRange("total_comments").build(ChangeData::totalCommentCount); |
| |
| /** Whether the change is mergeable. */ |
| public static final FieldDef<ChangeData, String> MERGEABLE = |
| exact(ChangeQueryBuilder.FIELD_MERGEABLE) |
| .stored() |
| .build( |
| cd -> { |
| Boolean m = cd.isMergeable(); |
| if (m == null) { |
| return null; |
| } |
| return m ? "1" : "0"; |
| }); |
| |
| /** The number of inserted lines in this change. */ |
| public static final FieldDef<ChangeData, Integer> ADDED = |
| intRange(ChangeQueryBuilder.FIELD_ADDED) |
| .build(cd -> cd.changedLines().isPresent() ? cd.changedLines().get().insertions : null); |
| |
| /** The number of deleted lines in this change. */ |
| public static final FieldDef<ChangeData, Integer> DELETED = |
| intRange(ChangeQueryBuilder.FIELD_DELETED) |
| .build(cd -> cd.changedLines().isPresent() ? cd.changedLines().get().deletions : null); |
| |
| /** The total number of modified lines in this change. */ |
| public static final FieldDef<ChangeData, Integer> DELTA = |
| intRange(ChangeQueryBuilder.FIELD_DELTA) |
| .build(cd -> cd.changedLines().map(c -> c.insertions + c.deletions).orElse(null)); |
| |
| /** Determines if this change is private. */ |
| public static final FieldDef<ChangeData, String> PRIVATE = |
| exact(ChangeQueryBuilder.FIELD_PRIVATE).build(cd -> cd.change().isPrivate() ? "1" : "0"); |
| |
| /** Determines if this change is work in progress. */ |
| public static final FieldDef<ChangeData, String> WIP = |
| exact(ChangeQueryBuilder.FIELD_WIP).build(cd -> cd.change().isWorkInProgress() ? "1" : "0"); |
| |
| /** Determines if this change has started review. */ |
| public static final FieldDef<ChangeData, String> STARTED = |
| exact(ChangeQueryBuilder.FIELD_STARTED) |
| .build(cd -> cd.change().hasReviewStarted() ? "1" : "0"); |
| |
| /** Users who have commented on this change. */ |
| public static final FieldDef<ChangeData, Iterable<Integer>> COMMENTBY = |
| integer(ChangeQueryBuilder.FIELD_COMMENTBY) |
| .buildRepeatable( |
| 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())); |
| |
| /** Star labels on this change in the format: <account-id>:<label> */ |
| 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())); |
| |
| /** 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.getGroups().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())); |
| |
| /** 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()); |
| }); |
| |
| public static final SubmitRuleOptions SUBMIT_RULE_OPTIONS_LENIENT = |
| SubmitRuleOptions.builder().allowClosed(true).build(); |
| |
| public static final SubmitRuleOptions SUBMIT_RULE_OPTIONS_STRICT = |
| SubmitRuleOptions.builder().build(); |
| |
| /** |
| * 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; |
| Map<String, String> data; |
| } |
| |
| SubmitRecord.Status status; |
| List<StoredLabel> labels; |
| List<StoredRequirement> requirements; |
| String errorMessage; |
| |
| public StoredSubmitRecord(SubmitRecord rec) { |
| 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 (SubmitRequirement requirement : rec.requirements) { |
| StoredRequirement sr = new StoredRequirement(); |
| sr.type = requirement.type(); |
| sr.fallbackText = requirement.fallbackText(); |
| sr.data = requirement.data(); |
| this.requirements.add(sr); |
| } |
| } |
| } |
| |
| public SubmitRecord toSubmitRecord() { |
| SubmitRecord rec = new SubmitRecord(); |
| 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 ? new Account.Id(label.appliedBy) : null; |
| rec.labels.add(srl); |
| } |
| } |
| if (requirements != null) { |
| rec.requirements = new ArrayList<>(requirements.size()); |
| for (StoredRequirement req : requirements) { |
| SubmitRequirement sr = |
| SubmitRequirement.builder() |
| .setType(req.type) |
| .setFallbackText(req.fallbackText) |
| .setData(req.data) |
| .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)); |
| |
| public static final FieldDef<ChangeData, Iterable<byte[]>> STORED_SUBMIT_RECORD_LENIENT = |
| storedOnly("full_submit_record_lenient") |
| .buildRepeatable(cd -> storedSubmitRecords(cd, SUBMIT_RULE_OPTIONS_LENIENT)); |
| |
| public static void parseSubmitRecords( |
| Collection<String> values, SubmitRuleOptions opts, ChangeData out) { |
| List<SubmitRecord> records = parseSubmitRecords(values); |
| if (records.isEmpty()) { |
| // Assume no values means the field is not in the index; |
| // SubmitRuleEvaluator ensures the list is non-empty. |
| return; |
| } |
| 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) { |
| return formatSubmitRecordValues( |
| cd.submitRecords(SUBMIT_RULE_OPTIONS_STRICT), cd.change().getOwner()); |
| } |
| |
| @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; |
| } |
| |
| /** |
| * 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<>(); |
| Project.NameKey project = cd.change().getProject(); |
| |
| cd.editRefs() |
| .values() |
| .forEach(r -> result.add(RefState.of(r).toByteArray(project))); |
| cd.starRefs() |
| .values() |
| .forEach(r -> result.add(RefState.of(r.ref()).toByteArray(allUsers(cd)))); |
| |
| ChangeNotes notes = cd.notes(); |
| result.add( |
| RefState.create(notes.getRefName(), notes.getMetaId()).toByteArray(project)); |
| notes.getRobotComments(); // Force loading robot comments. |
| RobotCommentNotes robotNotes = notes.getRobotCommentNotes(); |
| result.add( |
| RefState.create(robotNotes.getRefName(), robotNotes.getMetaId()) |
| .toByteArray(project)); |
| cd.draftRefs() |
| .values() |
| .forEach(r -> result.add(RefState.of(r).toByteArray(allUsers(cd)))); |
| |
| return result; |
| }); |
| |
| /** |
| * 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; |
| }); |
| |
| 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> FieldDef.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(); |
| } |
| } |