Add attention set to index

Change-Id: I22d2aa7b73cbbdf0d24cc4e49a0263705ea0229d
This commit is contained in:
Joerg Zieren
2020-03-24 14:41:20 +01:00
parent 6318b62300
commit 82b6f4f848
13 changed files with 258 additions and 13 deletions

View File

@@ -73,6 +73,11 @@ assignee:'USER'::
+ +
Changes assigned to the given user. Changes assigned to the given user.
[[attention]]
attention:'USER'::
+
Changes whose attention set includes the given user.
[[before_until]] [[before_until]]
before:'TIME'/until:'TIME':: before:'TIME'/until:'TIME'::
+ +

View File

@@ -405,6 +405,15 @@ class ElasticChangeIndex extends AbstractElasticIndex<Change.Id, ChangeData>
// Unresolved-comment-count. // Unresolved-comment-count.
decodeUnresolvedCommentCount(source, ChangeField.UNRESOLVED_COMMENT_COUNT.getName(), cd); decodeUnresolvedCommentCount(source, ChangeField.UNRESOLVED_COMMENT_COUNT.getName(), cd);
// Attention set.
if (fields.contains(ChangeField.ATTENTION_SET_FULL.getName())) {
ChangeField.parseAttentionSet(
FluentIterable.from(source.getAsJsonArray(ChangeField.ATTENTION_SET_FULL.getName()))
.transform(JsonElement::getAsString)
.toSet(),
cd);
}
return cd; return cd;
} }

View File

@@ -91,7 +91,9 @@ public final class FieldDef<I, T> {
private final String name; private final String name;
private final FieldType<?> type; private final FieldType<?> type;
/** Allow reading the actual data from the index. */
private final boolean stored; private final boolean stored;
private final boolean repeatable; private final boolean repeatable;
private final Getter<I, T> getter; private final Getter<I, T> getter;

View File

@@ -15,6 +15,7 @@
package com.google.gerrit.lucene; package com.google.gerrit.lucene;
import static com.google.common.collect.ImmutableList.toImmutableList; import static com.google.common.collect.ImmutableList.toImmutableList;
import static com.google.common.collect.ImmutableSet.toImmutableSet;
import static com.google.gerrit.lucene.AbstractLuceneIndex.sortFieldName; import static com.google.gerrit.lucene.AbstractLuceneIndex.sortFieldName;
import static com.google.gerrit.server.git.QueueProvider.QueueType.INTERACTIVE; import static com.google.gerrit.server.git.QueueProvider.QueueType.INTERACTIVE;
import static com.google.gerrit.server.index.change.ChangeField.LEGACY_ID; import static com.google.gerrit.server.index.change.ChangeField.LEGACY_ID;
@@ -138,6 +139,7 @@ public class LuceneChangeIndex implements ChangeIndex {
private static final String TOTAL_COMMENT_COUNT_FIELD = ChangeField.TOTAL_COMMENT_COUNT.getName(); private static final String TOTAL_COMMENT_COUNT_FIELD = ChangeField.TOTAL_COMMENT_COUNT.getName();
private static final String UNRESOLVED_COMMENT_COUNT_FIELD = private static final String UNRESOLVED_COMMENT_COUNT_FIELD =
ChangeField.UNRESOLVED_COMMENT_COUNT.getName(); ChangeField.UNRESOLVED_COMMENT_COUNT.getName();
private static final String ATTENTION_SET_FULL_FIELD = ChangeField.ATTENTION_SET_FULL.getName();
@FunctionalInterface @FunctionalInterface
static interface IdTerm { static interface IdTerm {
@@ -548,6 +550,9 @@ public class LuceneChangeIndex implements ChangeIndex {
if (fields.contains(PENDING_REVIEWER_BY_EMAIL_FIELD)) { if (fields.contains(PENDING_REVIEWER_BY_EMAIL_FIELD)) {
decodePendingReviewersByEmail(doc, cd); decodePendingReviewersByEmail(doc, cd);
} }
if (fields.contains(ATTENTION_SET_FULL_FIELD)) {
decodeAttentionSet(doc, cd);
}
decodeSubmitRecords( decodeSubmitRecords(
doc, SUBMIT_RECORD_STRICT_FIELD, ChangeField.SUBMIT_RULE_OPTIONS_STRICT, cd); doc, SUBMIT_RECORD_STRICT_FIELD, ChangeField.SUBMIT_RULE_OPTIONS_STRICT, cd);
decodeSubmitRecords( decodeSubmitRecords(
@@ -672,6 +677,14 @@ public class LuceneChangeIndex implements ChangeIndex {
.transform(IndexableField::stringValue))); .transform(IndexableField::stringValue)));
} }
private void decodeAttentionSet(ListMultimap<String, IndexableField> doc, ChangeData cd) {
ChangeField.parseAttentionSet(
doc.get(ATTENTION_SET_FULL_FIELD).stream()
.map(field -> field.binaryValue().utf8ToString())
.collect(toImmutableSet()),
cd);
}
private void decodeSubmitRecords( private void decodeSubmitRecords(
ListMultimap<String, IndexableField> doc, ListMultimap<String, IndexableField> doc,
String field, String field,

View File

@@ -33,6 +33,7 @@ import static com.google.gerrit.extensions.client.ListChangesOption.SKIP_DIFFSTA
import static com.google.gerrit.extensions.client.ListChangesOption.SUBMITTABLE; import static com.google.gerrit.extensions.client.ListChangesOption.SUBMITTABLE;
import static com.google.gerrit.extensions.client.ListChangesOption.TRACKING_IDS; import static com.google.gerrit.extensions.client.ListChangesOption.TRACKING_IDS;
import static com.google.gerrit.server.ChangeMessagesUtil.createChangeMessageInfo; import static com.google.gerrit.server.ChangeMessagesUtil.createChangeMessageInfo;
import static com.google.gerrit.server.util.AttentionSetUtil.additionsOnly;
import static java.util.stream.Collectors.toList; import static java.util.stream.Collectors.toList;
import com.google.common.base.Joiner; import com.google.common.base.Joiner;
@@ -50,7 +51,6 @@ import com.google.gerrit.common.data.SubmitRecord.Status;
import com.google.gerrit.common.data.SubmitRequirement; import com.google.gerrit.common.data.SubmitRequirement;
import com.google.gerrit.common.data.SubmitTypeRecord; import com.google.gerrit.common.data.SubmitTypeRecord;
import com.google.gerrit.entities.Account; import com.google.gerrit.entities.Account;
import com.google.gerrit.entities.AttentionSetUpdate;
import com.google.gerrit.entities.Change; import com.google.gerrit.entities.Change;
import com.google.gerrit.entities.ChangeMessage; import com.google.gerrit.entities.ChangeMessage;
import com.google.gerrit.entities.PatchSet; import com.google.gerrit.entities.PatchSet;
@@ -510,9 +510,8 @@ public class ChangeJson {
out.topic = in.getTopic(); out.topic = in.getTopic();
if (!cd.attentionSet().isEmpty()) { if (!cd.attentionSet().isEmpty()) {
out.attentionSet = out.attentionSet =
cd.attentionSet().stream() // This filtering should match GetAttentionSet.
// This filtering should match GetAttentionSet. additionsOnly(cd.attentionSet()).stream()
.filter(a -> a.operation() == AttentionSetUpdate.Operation.ADD)
.collect( .collect(
toImmutableMap( toImmutableMap(
a -> a.account().get(), a -> a.account().get(),

View File

@@ -24,6 +24,7 @@ import static com.google.gerrit.index.FieldDef.integer;
import static com.google.gerrit.index.FieldDef.prefix; import static com.google.gerrit.index.FieldDef.prefix;
import static com.google.gerrit.index.FieldDef.storedOnly; import static com.google.gerrit.index.FieldDef.storedOnly;
import static com.google.gerrit.index.FieldDef.timestamp; 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.nio.charset.StandardCharsets.UTF_8;
import static java.util.stream.Collectors.joining; import static java.util.stream.Collectors.joining;
import static java.util.stream.Collectors.toList; import static java.util.stream.Collectors.toList;
@@ -45,6 +46,8 @@ import com.google.common.primitives.Longs;
import com.google.gerrit.common.data.SubmitRecord; import com.google.gerrit.common.data.SubmitRecord;
import com.google.gerrit.common.data.SubmitRequirement; import com.google.gerrit.common.data.SubmitRequirement;
import com.google.gerrit.entities.Account; import com.google.gerrit.entities.Account;
import com.google.gerrit.entities.AttentionSetUpdate;
import com.google.gerrit.entities.AttentionSetUpdate.Operation;
import com.google.gerrit.entities.Change; import com.google.gerrit.entities.Change;
import com.google.gerrit.entities.ChangeMessage; import com.google.gerrit.entities.ChangeMessage;
import com.google.gerrit.entities.PatchSetApproval; import com.google.gerrit.entities.PatchSetApproval;
@@ -74,6 +77,7 @@ import com.google.gerrit.server.query.change.ChangeQueryBuilder;
import com.google.gerrit.server.query.change.ChangeStatusPredicate; import com.google.gerrit.server.query.change.ChangeStatusPredicate;
import com.google.gson.Gson; import com.google.gson.Gson;
import java.sql.Timestamp; import java.sql.Timestamp;
import java.time.Instant;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Collection; import java.util.Collection;
import java.util.HashSet; import java.util.HashSet;
@@ -288,6 +292,45 @@ public class ChangeField {
? cd.change().getCherryPickOf().get() ? cd.change().getCherryPickOf().get()
: null); : null);
/** This class decouples the internal and API types from storage. */
private static class StoredAttentionSetEntry {
final long timestampMillis;
final int userId;
final String reason;
final 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
*/
public static final FieldDef<ChangeData, Iterable<Integer>> ATTENTION_SET_USERS =
integer(ChangeQueryBuilder.FIELD_ATTENTION_SET_USERS)
.buildRepeatable(ChangeField::getAttentionSetUserIds);
/**
* The full attention set data including timestamp, reason and possible future fields.
*
* @see #ATTENTION_SET_USERS
*/
public static final FieldDef<ChangeData, Iterable<byte[]>> ATTENTION_SET_FULL =
storedOnly(ChangeQueryBuilder.FIELD_ATTENTION_SET_FULL)
.buildRepeatable(ChangeField::storedAttentionSet);
/** The user assigned to the change. */ /** The user assigned to the change. */
public static final FieldDef<ChangeData, Integer> ASSIGNEE = public static final FieldDef<ChangeData, Integer> ASSIGNEE =
integer(ChangeQueryBuilder.FIELD_ASSIGNEE) integer(ChangeQueryBuilder.FIELD_ASSIGNEE)
@@ -463,6 +506,33 @@ public class ChangeField {
return ReviewerByEmailSet.fromTable(b.build()); return ReviewerByEmailSet.fromTable(b.build());
} }
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. */ /** Commit ID of any patch set on the change, using prefix match. */
public static final FieldDef<ChangeData, Iterable<String>> COMMIT = public static final FieldDef<ChangeData, Iterable<String>> COMMIT =
prefix(ChangeQueryBuilder.FIELD_COMMIT).buildRepeatable(ChangeField::getRevisions); prefix(ChangeQueryBuilder.FIELD_COMMIT).buildRepeatable(ChangeField::getRevisions);

View File

@@ -85,12 +85,17 @@ public class ChangeSchemaDefinitions extends SchemaDefinitions<ChangeData> {
ChangeField.UPDATED, ChangeField.UPDATED,
ChangeField.WIP); ChangeField.WIP);
// The computation of the 'extension' field is changed, hence reindexing is required. /**
* The computation of the {@link ChangeField#EXTENSION} field is changed, hence reindexing is
* required.
*/
@Deprecated static final Schema<ChangeData> V56 = schema(V55); @Deprecated static final Schema<ChangeData> V56 = schema(V55);
// New numeric types: use dimensional points using the k-d tree geo-spatial data structure /**
// to offer fast single- and multi-dimensional numeric range. As the consequense, integer * New numeric types: use dimensional points using the k-d tree geo-spatial data structure to
// document id type is replaced with string document id type. * offer fast single- and multi-dimensional numeric range. As the consequense, {@link
* ChangeField#LEGACY_ID} is replaced with {@link ChangeField#LEGACY_ID_STR}.
*/
@Deprecated @Deprecated
static final Schema<ChangeData> V57 = static final Schema<ChangeData> V57 =
new Schema.Builder<ChangeData>() new Schema.Builder<ChangeData>()
@@ -100,7 +105,11 @@ public class ChangeSchemaDefinitions extends SchemaDefinitions<ChangeData> {
.legacyNumericFields(false) .legacyNumericFields(false)
.build(); .build();
// Add new field CHERRY_PICK_OF /**
* Added new fields {@link ChangeField#CHERRY_PICK_OF_CHANGE} and {@link
* ChangeField#CHERRY_PICK_OF_PATCHSET}.
*/
@Deprecated
static final Schema<ChangeData> V58 = static final Schema<ChangeData> V58 =
new Schema.Builder<ChangeData>() new Schema.Builder<ChangeData>()
.add(V57) .add(V57)
@@ -108,6 +117,17 @@ public class ChangeSchemaDefinitions extends SchemaDefinitions<ChangeData> {
.add(ChangeField.CHERRY_PICK_OF_PATCHSET) .add(ChangeField.CHERRY_PICK_OF_PATCHSET)
.build(); .build();
/**
* Added new fields {@link ChangeField#ATTENTION_SET_USERS} and {@link
* ChangeField#ATTENTION_SET_FULL}.
*/
static final Schema<ChangeData> V59 =
new Schema.Builder<ChangeData>()
.add(V58)
.add(ChangeField.ATTENTION_SET_USERS)
.add(ChangeField.ATTENTION_SET_FULL)
.build();
/** /**
* Name of the change index to be used when contacting index backends or loading configurations. * Name of the change index to be used when contacting index backends or loading configurations.
*/ */

View File

@@ -0,0 +1,41 @@
// Copyright (C) 2020 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.query.change;
import static com.google.gerrit.server.util.AttentionSetUtil.additionsOnly;
import com.google.gerrit.entities.Account;
import com.google.gerrit.server.index.change.ChangeField;
/** Simple predicate for searching by attention set. */
public class AttentionSetPredicate extends ChangeIndexPredicate {
protected final Account.Id id;
AttentionSetPredicate(Account.Id id) {
super(ChangeField.ATTENTION_SET_USERS, id.toString());
this.id = id;
}
@Override
public boolean match(ChangeData changeData) {
return additionsOnly(changeData.attentionSet()).stream()
.anyMatch(update -> update.account().equals(id));
}
@Override
public int getCost() {
return 1;
}
}

View File

@@ -611,6 +611,21 @@ public class ChangeData {
return attentionSet; return attentionSet;
} }
/**
* Sets the specified attention set. If two or more entries refer to the same user, throws an
* {@link IllegalStateException}.
*/
public void setAttentionSet(ImmutableSet<AttentionSetUpdate> attentionSet) {
if (attentionSet.stream().map(AttentionSetUpdate::account).distinct().count()
!= attentionSet.size()) {
throw new IllegalStateException(
String.format(
"Stored attention set for change %d contains duplicate update",
change.getId().get()));
}
this.attentionSet = attentionSet;
}
/** @return patches for the change, in patch set ID order. */ /** @return patches for the change, in patch set ID order. */
public Collection<PatchSet> patchSets() { public Collection<PatchSet> patchSets() {
if (patchSets == null) { if (patchSets == null) {

View File

@@ -14,6 +14,7 @@
package com.google.gerrit.server.query.change; package com.google.gerrit.server.query.change;
import static com.google.common.collect.ImmutableSet.toImmutableSet;
import static com.google.gerrit.entities.Change.CHANGE_ID_PATTERN; import static com.google.gerrit.entities.Change.CHANGE_ID_PATTERN;
import static com.google.gerrit.server.account.AccountResolver.isSelf; import static com.google.gerrit.server.account.AccountResolver.isSelf;
import static com.google.gerrit.server.query.change.ChangeData.asChanges; import static com.google.gerrit.server.query.change.ChangeData.asChanges;
@@ -136,6 +137,8 @@ public class ChangeQueryBuilder extends QueryBuilder<ChangeData, ChangeQueryBuil
public static final String FIELD_ADDED = "added"; public static final String FIELD_ADDED = "added";
public static final String FIELD_AGE = "age"; public static final String FIELD_AGE = "age";
public static final String FIELD_ATTENTION_SET_USERS = "attentionusers";
public static final String FIELD_ATTENTION_SET_FULL = "attentionfull";
public static final String FIELD_ASSIGNEE = "assignee"; public static final String FIELD_ASSIGNEE = "assignee";
public static final String FIELD_AUTHOR = "author"; public static final String FIELD_AUTHOR = "author";
public static final String FIELD_EXACTAUTHOR = "exactauthor"; public static final String FIELD_EXACTAUTHOR = "exactauthor";
@@ -1056,6 +1059,20 @@ public class ChangeQueryBuilder extends QueryBuilder<ChangeData, ChangeQueryBuil
return owner(accounts); return owner(accounts);
} }
@Operator
public Predicate<ChangeData> attention(String who)
throws QueryParseException, IOException, ConfigInvalidException {
if (!args.index.getSchema().hasField(ChangeField.ATTENTION_SET_USERS)) {
throw new QueryParseException(
"'attention' operator is not supported by change index version");
}
return attention(parseAccount(who, (AccountState s) -> true));
}
private Predicate<ChangeData> attention(Set<Account.Id> who) {
return Predicate.or(who.stream().map(AttentionSetPredicate::new).collect(toImmutableSet()));
}
@Operator @Operator
public Predicate<ChangeData> assignee(String who) public Predicate<ChangeData> assignee(String who)
throws QueryParseException, IOException, ConfigInvalidException { throws QueryParseException, IOException, ConfigInvalidException {

View File

@@ -15,9 +15,9 @@
package com.google.gerrit.server.restapi.change; package com.google.gerrit.server.restapi.change;
import static com.google.common.collect.ImmutableSet.toImmutableSet; import static com.google.common.collect.ImmutableSet.toImmutableSet;
import static com.google.gerrit.server.util.AttentionSetUtil.additionsOnly;
import com.google.common.collect.ImmutableSet; import com.google.common.collect.ImmutableSet;
import com.google.gerrit.entities.AttentionSetUpdate.Operation;
import com.google.gerrit.extensions.common.AttentionSetEntry; import com.google.gerrit.extensions.common.AttentionSetEntry;
import com.google.gerrit.extensions.restapi.Response; import com.google.gerrit.extensions.restapi.Response;
import com.google.gerrit.extensions.restapi.RestReadView; import com.google.gerrit.extensions.restapi.RestReadView;
@@ -45,9 +45,8 @@ public class GetAttentionSet implements RestReadView<ChangeResource> {
throws PermissionBackendException { throws PermissionBackendException {
AccountLoader accountLoader = accountLoaderFactory.create(true); AccountLoader accountLoader = accountLoaderFactory.create(true);
ImmutableSet<AttentionSetEntry> response = ImmutableSet<AttentionSetEntry> response =
changeResource.getNotes().getAttentionSet().stream() // This filtering should match ChangeJson.
// This filtering should match ChangeJson. additionsOnly(changeResource.getNotes().getAttentionSet()).stream()
.filter(a -> a.operation() == Operation.ADD)
.map( .map(
a -> a ->
new AttentionSetEntry( new AttentionSetEntry(

View File

@@ -0,0 +1,19 @@
package com.google.gerrit.server.util;
import com.google.common.collect.ImmutableSet;
import com.google.gerrit.entities.AttentionSetUpdate;
import com.google.gerrit.entities.AttentionSetUpdate.Operation;
import java.util.Collection;
/** Common helpers for dealing with attention set data structures. */
public class AttentionSetUtil {
/** Returns only updates where the user was added. */
public static ImmutableSet<AttentionSetUpdate> additionsOnly(
Collection<AttentionSetUpdate> updates) {
return updates.stream()
.filter(u -> u.operation() == Operation.ADD)
.collect(ImmutableSet.toImmutableSet());
}
private AttentionSetUtil() {}
}

View File

@@ -54,6 +54,7 @@ import com.google.gerrit.entities.Project;
import com.google.gerrit.entities.RefNames; import com.google.gerrit.entities.RefNames;
import com.google.gerrit.extensions.api.GerritApi; import com.google.gerrit.extensions.api.GerritApi;
import com.google.gerrit.extensions.api.changes.AddReviewerInput; import com.google.gerrit.extensions.api.changes.AddReviewerInput;
import com.google.gerrit.extensions.api.changes.AddToAttentionSetInput;
import com.google.gerrit.extensions.api.changes.AssigneeInput; import com.google.gerrit.extensions.api.changes.AssigneeInput;
import com.google.gerrit.extensions.api.changes.ChangeApi; import com.google.gerrit.extensions.api.changes.ChangeApi;
import com.google.gerrit.extensions.api.changes.Changes.QueryRequest; import com.google.gerrit.extensions.api.changes.Changes.QueryRequest;
@@ -2940,6 +2941,41 @@ public abstract class AbstractQueryChangesTest extends GerritServerTests {
mergedOwned); mergedOwned);
} }
@Test
public void attentionSetIndexed() throws Exception {
assume().that(getSchema().hasField(ChangeField.ATTENTION_SET_USERS)).isTrue();
TestRepository<Repo> repo = createProject("repo");
Change change1 = insert(repo, newChange(repo));
Change change2 = insert(repo, newChange(repo));
AddToAttentionSetInput input = new AddToAttentionSetInput(userId.toString(), "some reason");
gApi.changes().id(change1.getChangeId()).addToAttentionSet(input);
assertQuery("attention:" + user.getUserName().get(), change1);
assertQuery("-attention:" + userId.toString(), change2);
}
@Test
public void attentionSetStored() throws Exception {
assume().that(getSchema().hasField(ChangeField.ATTENTION_SET_USERS)).isTrue();
TestRepository<Repo> repo = createProject("repo");
Change change = insert(repo, newChange(repo));
AddToAttentionSetInput input = new AddToAttentionSetInput(userId.toString(), "reason 1");
gApi.changes().id(change.getChangeId()).addToAttentionSet(input);
Account.Id user2Id =
accountManager.authenticate(AuthRequest.forUser("anotheruser")).getAccountId();
input = new AddToAttentionSetInput(user2Id.toString(), "reason 2");
gApi.changes().id(change.getChangeId()).addToAttentionSet(input);
List<ChangeInfo> result = newQuery("attention:" + user2Id.toString()).get();
assertThat(result).hasSize(1);
ChangeInfo changeInfo = Iterables.getOnlyElement(result);
assertThat(changeInfo.attentionSet.keySet()).containsExactly(userId.get(), user2Id.get());
assertThat(changeInfo.attentionSet.get(userId.get()).reason).isEqualTo("reason 1");
assertThat(changeInfo.attentionSet.get(user2Id.get()).reason).isEqualTo("reason 2");
}
@Test @Test
public void assignee() throws Exception { public void assignee() throws Exception {
TestRepository<Repo> repo = createProject("repo"); TestRepository<Repo> repo = createProject("repo");