Merge "PostReview: Fix email for removing an email without Gerrit account as CC"
diff --git a/Documentation/config-project-config.txt b/Documentation/config-project-config.txt
index dfc38c3..7a0e305 100644
--- a/Documentation/config-project-config.txt
+++ b/Documentation/config-project-config.txt
@@ -319,11 +319,64 @@
[[content_merge]]submit.mergeContent::
+
-Defines whether Gerrit will try to
-do a content merge when a path conflict occurs. Valid values are
-'true', 'false', or 'INHERIT'. Default is 'INHERIT'. This option can
-be modified by any project owner through the project console, `Browse`
-> `Repositories` > my/project > `Allow content merges`.
+Defines whether Gerrit will try to do a content merge when a path conflict
+occurs while submitting a change.
++
+A path conflict occurs when the same file has been changed on both sides of a
+merge, e.g. when the same file has been touched in a change and concurrently in
+the target branch.
++
+Doing a content merge means that Gerrit attempts to merge the conflicting file
+contents from both sides of the merge. This is successful if the touched lines
+(plus some surrounding context lines) do not overlap (i.e. both sides touch
+distinct lines).
++
+NOTE: The content merge setting is not relevant when
+link:#fast_forward_only[fast forward only] is configured as the
+link:#submit.action[submit action] because in this case Gerrit will never
+perform a merge, rebase or cherry-pick on submit.
++
+If content merges are disabled, the submit button in the Gerrit web UI is
+disabled, if any path conflict would occur on submitting the change. Users then
+need to rebase the change manually to resolve the path conflict and then get
+the change re-approved so that they can submit it.
++
+NOTE: If only distinct lines have been touched on both sides, rebasing the
+change from the Gerrit UI is sufficient to resolve the path conflict, since the
+rebase action always does the rebase with content merge enabled.
++
+The advantage of enabling content merges on submit is that it makes it less
+likely that change submissions are rejected due to conflicts. Each change
+submission that goes through with content merge, but would be rejected
+otherwise, saves the user from needing to do extra work to get the change
+submitted (rebase the change, get it re-approved and then submit it again).
++
+On the other hand, disabling content merges decreases the chance of breaking
+branches by submitting content merges of incompatible modifications in the same
+file, e.g. a function is removed on one side and a new usage of that function
+is added on the other side. Note, that the chance of breaking a branch by
+incompatible modifications is only reduced, but not eliminated, e.g. even with
+content merges disabled it's possible that a function is removed in one file
+and a new usage of that function is added in another file.
++
+The huge drawback of disabling content merge is that users need to do extra
+work when a change isn't submittable due to a path conflict which could be
+avoided if content merge was enabled (see above). In addition to this, it also
+confuses and frustrates users if a change submission is rejected by Gerrit due
+to a path conflict, but then when they rebase the change manually they do not
+see any conflict (because manual rebases are always done with content merge
+enabled).
++
+In general, the stability gain of disabling content merges is not worth the
+overhead and confusion that this adds for users, which is why disabling content
+merges is highly discouraged.
++
+Valid values are `true`, `false`, or `INHERIT`.
++
+The default is `INHERIT`.
++
+NOTE: Project owners can also set this option in the Gerrit UI:
+`Browse` > `Repositories` > my/repository > `Allow content merges`.
[[submit.action]]submit.action::
+
@@ -342,55 +395,136 @@
[[merge_if_necessary]]
* 'merge if necessary':
+
-If the change being submitted is a strict superset of the destination branch,
-then the branch is fast-forwarded to the change. If not, then a merge commit is
-automatically created. This is identical to the classical `git merge` behavior,
- or `git merge --ff`.
+With this action, when a change is being submitted, Gerrit fast-forwards the
+target branch if possible, and otherwise creates a merge commit automatically.
++
+A fast-forward is possible if the commit that represents the current patch set
+of the change has the current head of the target branch in its parent lineage.
++
+If a fast-forward is not possible, Gerrit automatically creates a merge commit
+that merges the current patch set of the change into the target branch and then
+the target branch is fast-forwarded to the merge commit.
++
+The behavior of this submit action is identical with the classical `git merge`
+behavior, or
+link:https://git-scm.com/docs/git-merge#Documentation/git-merge.txt---ff[git
+merge --ff].
++
+With this submit action the commits that have been reviewed and approved are
+retained in the git history of the target branch. This means, by looking at the
+history of the target branch, you can see for all commits when they were
+originally committed and on which parent commit they were originally based.
[[always_merge]]
[[merge_always]]
* 'merge always':
+
-Always produce a merge commit, even if the change is a strict superset of the
-destination branch. This is identical to the behavior of `git merge --no-ff`,
-and may be useful if the project needs to follow submits with
-`git log --first-parent`.
+With this action, when a change is being submitted, Gerrit always creates a
+merge commit, even if a fast-forward is possible.
++
+This is identical to the behavior of
+link:https://git-scm.com/docs/git-merge#Documentation/git-merge.txt---no-ff[git merge --no-ff].
++
+With this submit action the commits that have been reviewed and approved are
+retained in the git history of the target branch. This means, by looking at the
+history of the target branch, you can see for all commits when they were
+originally committed and on which parent commit they were originally based. In
+addition, from the merge commits you can see when the changes were submitted
+and it's possible to follow submissions with `git log --first-parent`.
[[rebase_if_necessary]]
* 'rebase if necessary':
+
-If the change being submitted is a strict superset of the destination branch,
-then the branch is fast-forwarded to the change. If not, then the change is
-automatically rebased and then the branch is fast-forwarded to the change.
+With this action, when a change is being submitted, Gerrit fast-forwards the
+target branch if possible, and otherwise does a rebase automatically.
+
-When Gerrit tries to do a merge, by default the merge will only succeed if
-there is no path conflict. A path conflict occurs when the same file has also
-been changed on the other side of the merge.
+A fast-forward is possible if the commit that represents the current patch set
+of the change has the current head of the target branch in its parent lineage.
++
+If a fast-forward is not possible, Gerrit automatically rebases the current
+patch set of the change on top of the current head of the target branch and
+then the target branch is fast-forwarded to the rebased commit.
++
+With this submit action, when a rebase is performed, the original commits that
+have been reviewed and approved do not become part of the target branch's
+history. This means the information on when the original commits were committed
+and on which parent they were based is not retained in the branch history.
++
+Using this submit action results in a linear history of the target branch,
+unless merge commits are being submitted. For some people this is an advantage
+since they find the linear history easier to read.
++
+NOTE: Rebasing merge commits is not supported. If a change with a merge commit
+is submitted the link:#merge_if_necessary[merge if necessary] submit action is
+applied.
++
+When rebasing the patchset, Gerrit automatically appends onto the end of the
+commit message a short summary of the change's approvals, and a URL link back
+to the change in the web UI (see link:#submit-footers[below]). If a fast-forward
+is done no footers are added.
[[rebase_always]]
* 'rebase always':
+
-Basically, the same as `rebase if necessary`, but it creates a new patchset
-even if fast forward is possible and like `cherry pick` it ensures footers such
-as `Change-Id`, `Reviewed-On`, and others are present in resulting commit that
-is merged.
+With this action, when a change is being submitted, Gerrit always does a
+rebase, even if a fast-forward is possible.
+
-Thus, `rebase always` can be considered similar to `cherry pick`, but with the
-important distinction that `rebase always` does not ignore dependencies.
+With this submit action, the original commits that have been reviewed and
+approved do not become part of the target branch's history. This means the
+information on when the original commits were committed and on which parent
+they were based is not retained in the branch history.
++
+Using this submit action results in a linear history of the target branch,
+unless merge commits are being submitted. For some people this is an advantage
+since they find the linear history easier to read.
++
+NOTE: Rebasing merge commits is not supported. If a change with a merge commit
+is submitted the link:#merge_if_necessary[merge if necessary] submit action is
+applied.
++
+When rebasing the patchset, Gerrit automatically appends onto the end of the
+commit message a short summary of the change's approvals, and a URL link back
+to the change in the web UI (see link:#submit-footers[below]).
++
+The footers that are added are exactly the same footers that are also added by
+the link:cherry_pick[cherry pick] action. Thus, the `rebase always` action can
+be considered similar to the `cherry pick` action, but with the important
+distinction that `rebase always` does not ignore dependencies, which is why
+using the `rebase always` action should be preferred over the `cherry pick`
+submit action.
[[fast_forward_only]]
-* 'fast forward only':
+* 'fast forward only' (usage generally not recommended):
+
-With this action Gerrit does not create merge commits on submitting a change.
-Merge commits may still be submitted, but they must be created on the client
-prior to uploading to Gerrit for review.
+With this action a change can only be submitted if at submit time the target
+branch can be fast-forwarded to the commit that represents the current patch
+set of the change. This means in order for a change to be submittable its
+current patch set must have the current head of the target branch in its parent
+lineage.
+
-To submit a change, the change must be a strict superset of the destination
-branch. That is, the change must already contain the tip of the destination
-branch at submit time.
+The advantage of using this action is that the target branch is always updated
+to the exact commit that has been reviewed and approved. In particular, if CI
+verification is configured, this means that the CI verified the exact commit to
+which the target branch is being fast-forwarded on submit (assuming no approval
+copying is configured for CI votes).
++
+The huge drawback of using this action is that whenever one change is submitted
+all other open changes for the same branch, that are not successors of the
+submitted change, become non-submittable, since the target branch can no longer
+be fast-forwarded to their current patch sets. Making these changes submittable
+again requires rebasing and re-approving/re-verifying them. For most projects
+this causes an unreasonable amount of overhead that doesn't justify the
+stability gain by verifying exact commits so that using this submit action is
+generally discouraged. Using this action should only be considered for projects
+that have a low frequency of changes and that have special requirements to
+never break any target branch.
++
+NOTE: With this submit action Gerrit does not create merge commits on
+submitting a change, but merge commits that are created on the client, prior to
+uploading to Gerrit for review, may still be submitted.
[[cherry_pick]]
-* 'cherry pick' (usage not recommended, user link:#rebase_always[rebase always]
+* 'cherry pick' (usage not recommended, use link:#rebase_always[rebase always]
instead):
+
With this submit action Gerrit always performs a cherry pick of the current
@@ -416,7 +550,7 @@
+
When cherry picking the patchset, Gerrit automatically appends onto the end of
the commit message a short summary of the change's approvals, and a URL link
-back to the change on the web.
+back to the change in the web UI (see link:#submit-footers[below]).
+
Using this submit action is not recommended because it ignores change
dependencies, instead link:#rebase_always[rebase always] should be used which
@@ -436,6 +570,49 @@
+
If `submit.action` is not set, the default is 'merge if necessary'.
+
+NOTE: The different submit actions are also described in the
+link:https://docs.google.com/presentation/d/1C73UgQdzZDw0gzpaEqIC6SPujZJhqamyqO1XOHjH-uk/edit#slide=id.g4d6c16487b_1_800[Gerrit - Concepts and Workflows]
+presentation, where their behavior is visualized by git commit graphs.
++
+NOTE: If Gerrit performs a merge, rebase or cherry-pick as part of the
+change submission (true for all submit actions, except for
+link:#fast_forward_only[fast forward only]), it is controlled by the
+link:#submit.mergeContent[mergeContent] setting whether a content merge is
+performed when there is a path conflict.
++
+NOTE: If Gerrit performs a merge, rebase or cherry-pick as part of the
+change submission (true for all submit actions, except for
+link:#fast_forward_only[fast forward only]), it can be that trying to submit
+a change would fail due to Git conflicts (if the same lines were modified
+concurrently, or if link:#submit.mergeContent[mergeContent] is disabled also if
+the same files were modified concurrently). In this case the submit button in
+the Gerrit web UI is disabled and a tooltip on the disabled submit button
+informs about the change being non-mergeable.
++
+[[submit-footers]]
+--
+NOTE: If Gerrit performs a rebase or cherry-pick as part of the change
+submission (true for link:#rebase_if_necessary[rebase if necessary],
+link:#rebase_always[rebase always] and link:#cherry_pick[cherry pick]) Gerrit
+inserts additional footers into the commit message of the newly created
+commit: +
+ +
+* `Change-Id: <change-id>` (only if this footer is not already present, see
+ link:user-changeid.html[Change-Id]) +
+* `Reviewed-on: <change-url>` (links to the change in Gerrit where this commit
+ was reviewed) +
+* `Reviewed-by: <reviewer>` (once for every reviewer with a positive
+ `Code-Review` vote) +
+* `Tested-by: <reviewer>` (once for every reviewer with a positive `Verified`
+ vote) +
+* `<label-name>: <reviewer>` (once for every reviewer with a positive vote on
+ any other label) +
+ +
+In addition, plugins that implement a
+link:dev-plugins.html#change-message-modifier[Change Message Modifier] may add
+additional custom footers.
+--
++
NOTE: For the value of `submit.action` in `project.config` use the exact
spelling as given above, e.g. 'merge if necessary' (without the single quotes,
but with the spaces).
diff --git a/Documentation/dev-roles.txt b/Documentation/dev-roles.txt
index cecaedc..d8f7e11 100644
--- a/Documentation/dev-roles.txt
+++ b/Documentation/dev-roles.txt
@@ -66,11 +66,12 @@
link:https://groups.google.com/d/forum/repo-discuss[repo-discuss,role=external,window=_blank]
mailing list):
-* become member of the `gerrit-verifiers` group, which allows to:
+* become member of the `gerrit-trusted-contributors` group, which allows to:
** vote on the `Verified` and `Code-Style` labels
** edit hashtags on all changes
** edit topics on all open changes
** abandon changes
+** revert changes
* approve posts to the
link:https://groups.google.com/d/forum/repo-discuss[repo-discuss,role=external,window=_blank]
mailing list
@@ -106,14 +107,14 @@
have. In addition they have signed a link:dev-cla.html[contributor
license agreement] which enables them to push changes.
-Regular contributors can ask to be added to the `gerrit-verifiers`
+Regular contributors can ask to be added to the `gerrit-trusted-contributors`
group, which allows to:
* add patch sets to changes of other users
* propose project config changes (push changes for the
`refs/meta/config` branch
-Being member of the `gerrit-verifiers` group includes further
+Being member of the `gerrit-trusted-contributors` group includes further
permissions (see link:#supporter[supporter] section above).
It's highly appreciated if contributors engage in code reviews,
diff --git a/Documentation/pg-plugin-endpoints.txt b/Documentation/pg-plugin-endpoints.txt
index f2a72f1..41f544d 100644
--- a/Documentation/pg-plugin-endpoints.txt
+++ b/Documentation/pg-plugin-endpoints.txt
@@ -141,6 +141,11 @@
=== header-title
This endpoint wraps the title-text in the application header.
+=== cherrypick-main
+This endpoint is located in the cherrypick dialog. It has two slots `top`
+and `bottom` and `changes` as a parameter with the list of changes (or
+just the one change) to be cherrypicked.
+
=== confirm-revert-change
This endpoint is inside the confirm revert dialog. By default it displays a
generic confirmation message regarding reverting the change. Plugins may add
diff --git a/Documentation/user-notify.txt b/Documentation/user-notify.txt
index 20ad07c..24c35f0 100644
--- a/Documentation/user-notify.txt
+++ b/Documentation/user-notify.txt
@@ -18,8 +18,8 @@
[[user]]
== User Level Settings
-Individual users can configure email subscriptions by editing
-watched projects through Settings > Watched Projects with the web UI.
+Individual users can configure email subscriptions by editing their
+notifications in the Web UI under `Settings` > `Notifications`.
Specific projects may be watched, or the special project
`All-Projects` can be watched to watch all projects that
diff --git a/java/com/google/gerrit/server/index/group/GroupField.java b/java/com/google/gerrit/server/index/group/GroupField.java
index 775bd5e..dd6aa3b 100644
--- a/java/com/google/gerrit/server/index/group/GroupField.java
+++ b/java/com/google/gerrit/server/index/group/GroupField.java
@@ -16,11 +16,7 @@
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.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 com.google.common.base.MoreObjects;
import com.google.gerrit.entities.Account;
@@ -28,6 +24,7 @@
import com.google.gerrit.entities.InternalGroup;
import com.google.gerrit.git.ObjectIds;
import com.google.gerrit.index.FieldDef;
+import com.google.gerrit.index.IndexedField;
import com.google.gerrit.index.SchemaUtil;
import java.sql.Timestamp;
import org.eclipse.jgit.lib.ObjectId;
@@ -40,49 +37,88 @@
*/
public class GroupField {
/** Legacy group ID. */
- public static final FieldDef<InternalGroup, Integer> ID =
- integer("id").build(g -> g.getId().get());
+ public static final IndexedField<InternalGroup, Integer> ID_FIELD =
+ IndexedField.<InternalGroup>integerBuilder("Id").required().build(g -> g.getId().get());
+
+ public static final IndexedField<InternalGroup, Integer>.SearchSpec ID_FIELD_SPEC =
+ ID_FIELD.integer("id");
/** Group UUID. */
public static final FieldDef<InternalGroup, String> UUID =
exact("uuid").stored().build(g -> g.getGroupUUID().get());
/** Group owner UUID. */
- public static final FieldDef<InternalGroup, String> OWNER_UUID =
- exact("owner_uuid").build(g -> g.getOwnerGroupUUID().get());
+ public static final IndexedField<InternalGroup, String> OWNER_UUID_FIELD =
+ IndexedField.<InternalGroup>stringBuilder("OwnerUUID")
+ .required()
+ .build(g -> g.getOwnerGroupUUID().get());
+
+ public static final IndexedField<InternalGroup, String>.SearchSpec OWNER_UUID_SPEC =
+ OWNER_UUID_FIELD.exact("owner_uuid");
/** Timestamp indicating when this group was created. */
// TODO(issue-15518): Migrate type for timestamp index fields from Timestamp to Instant
- public static final FieldDef<InternalGroup, Timestamp> CREATED_ON =
- timestamp("created_on").build(internalGroup -> Timestamp.from(internalGroup.getCreatedOn()));
+ public static final IndexedField<InternalGroup, Timestamp> CREATED_ON_FIELD =
+ IndexedField.<InternalGroup>timestampBuilder("CreatedOn")
+ .required()
+ .build(internalGroup -> Timestamp.from(internalGroup.getCreatedOn()));
+
+ public static final IndexedField<InternalGroup, Timestamp>.SearchSpec CREATED_ON_SPEC =
+ CREATED_ON_FIELD.timestamp("created_on");
/** Group name. */
- public static final FieldDef<InternalGroup, String> NAME =
- exact("name").build(InternalGroup::getName);
+ public static final IndexedField<InternalGroup, String> NAME_FIELD =
+ IndexedField.<InternalGroup>stringBuilder("Name")
+ .required()
+ .size(200)
+ .build(InternalGroup::getName);
+
+ public static final IndexedField<InternalGroup, String>.SearchSpec NAME_SPEC =
+ NAME_FIELD.exact("name");
/** Prefix match on group name parts. */
- public static final FieldDef<InternalGroup, Iterable<String>> NAME_PART =
- prefix("name_part").buildRepeatable(g -> SchemaUtil.getNameParts(g.getName()));
+ public static final IndexedField<InternalGroup, Iterable<String>> NAME_PART_FIELD =
+ IndexedField.<InternalGroup>iterableStringBuilder("NamePart")
+ .required()
+ .size(200)
+ .build(g -> SchemaUtil.getNameParts(g.getName()));
+
+ public static final IndexedField<InternalGroup, Iterable<String>>.SearchSpec NAME_PART_SPEC =
+ NAME_PART_FIELD.prefix("name_part");
/** Group description. */
- public static final FieldDef<InternalGroup, String> DESCRIPTION =
- fullText("description").build(InternalGroup::getDescription);
+ public static final IndexedField<InternalGroup, String> DESCRIPTION_FIELD =
+ IndexedField.<InternalGroup>stringBuilder("Description").build(InternalGroup::getDescription);
+
+ public static final IndexedField<InternalGroup, String>.SearchSpec DESCRIPTION_SPEC =
+ DESCRIPTION_FIELD.fullText("description");
/** Whether the group is visible to all users. */
- public static final FieldDef<InternalGroup, String> IS_VISIBLE_TO_ALL =
- exact("is_visible_to_all").build(g -> g.isVisibleToAll() ? "1" : "0");
+ public static final IndexedField<InternalGroup, String> IS_VISIBLE_TO_ALL_FIELD =
+ IndexedField.<InternalGroup>stringBuilder("IsVisibleToAll")
+ .required()
+ .size(1)
+ .build(g -> g.isVisibleToAll() ? "1" : "0");
- public static final FieldDef<InternalGroup, Iterable<Integer>> MEMBER =
- integer("member")
- .buildRepeatable(
- g -> g.getMembers().stream().map(Account.Id::get).collect(toImmutableList()));
+ public static final IndexedField<InternalGroup, String>.SearchSpec IS_VISIBLE_TO_ALL_SPEC =
+ IS_VISIBLE_TO_ALL_FIELD.exact("is_visible_to_all");
- public static final FieldDef<InternalGroup, Iterable<String>> SUBGROUP =
- exact("subgroup")
- .buildRepeatable(
+ public static final IndexedField<InternalGroup, Iterable<Integer>> MEMBER_FIELD =
+ IndexedField.<InternalGroup>iterableIntegerBuilder("Member")
+ .build(g -> g.getMembers().stream().map(Account.Id::get).collect(toImmutableList()));
+
+ public static final IndexedField<InternalGroup, Iterable<Integer>>.SearchSpec MEMBER_SPEC =
+ MEMBER_FIELD.integer("member");
+
+ public static final IndexedField<InternalGroup, Iterable<String>> SUBGROUP_FIELD =
+ IndexedField.<InternalGroup>iterableStringBuilder("Subgroup")
+ .build(
g ->
g.getSubgroups().stream().map(AccountGroup.UUID::get).collect(toImmutableList()));
+ public static final IndexedField<InternalGroup, Iterable<String>>.SearchSpec SUBGROUP_SPEC =
+ SUBGROUP_FIELD.exact("subgroup");
+
/** ObjectId of HEAD:refs/groups/<UUID>. */
public static final FieldDef<InternalGroup, byte[]> REF_STATE =
storedOnly("ref_state")
diff --git a/java/com/google/gerrit/server/index/group/GroupSchemaDefinitions.java b/java/com/google/gerrit/server/index/group/GroupSchemaDefinitions.java
index 5527345..2fba6ab 100644
--- a/java/com/google/gerrit/server/index/group/GroupSchemaDefinitions.java
+++ b/java/com/google/gerrit/server/index/group/GroupSchemaDefinitions.java
@@ -16,7 +16,9 @@
import static com.google.gerrit.index.SchemaUtil.schema;
+import com.google.common.collect.ImmutableList;
import com.google.gerrit.entities.InternalGroup;
+import com.google.gerrit.index.IndexedField;
import com.google.gerrit.index.Schema;
import com.google.gerrit.index.SchemaDefinitions;
@@ -31,17 +33,27 @@
static final Schema<InternalGroup> V5 =
schema(
/* version= */ 5,
- GroupField.CREATED_ON,
- GroupField.DESCRIPTION,
- GroupField.ID,
- GroupField.IS_VISIBLE_TO_ALL,
- GroupField.MEMBER,
- GroupField.NAME,
- GroupField.NAME_PART,
- GroupField.OWNER_UUID,
- GroupField.REF_STATE,
- GroupField.SUBGROUP,
- GroupField.UUID);
+ ImmutableList.of(GroupField.REF_STATE, GroupField.UUID),
+ ImmutableList.of(
+ GroupField.CREATED_ON_FIELD,
+ GroupField.DESCRIPTION_FIELD,
+ GroupField.ID_FIELD,
+ GroupField.IS_VISIBLE_TO_ALL_FIELD,
+ GroupField.MEMBER_FIELD,
+ GroupField.NAME_FIELD,
+ GroupField.NAME_PART_FIELD,
+ GroupField.OWNER_UUID_FIELD,
+ GroupField.SUBGROUP_FIELD),
+ ImmutableList.<IndexedField<InternalGroup, ?>.SearchSpec>of(
+ GroupField.CREATED_ON_SPEC,
+ GroupField.DESCRIPTION_SPEC,
+ GroupField.ID_FIELD_SPEC,
+ GroupField.IS_VISIBLE_TO_ALL_SPEC,
+ GroupField.MEMBER_SPEC,
+ GroupField.NAME_SPEC,
+ GroupField.NAME_PART_SPEC,
+ GroupField.OWNER_UUID_SPEC,
+ GroupField.SUBGROUP_SPEC));
// Bump Lucene version requires reindexing
@Deprecated static final Schema<InternalGroup> V6 = schema(V5);
diff --git a/java/com/google/gerrit/server/query/group/GroupPredicates.java b/java/com/google/gerrit/server/query/group/GroupPredicates.java
index 8a2dc8d..8d935d6 100644
--- a/java/com/google/gerrit/server/query/group/GroupPredicates.java
+++ b/java/com/google/gerrit/server/query/group/GroupPredicates.java
@@ -17,7 +17,7 @@
import com.google.gerrit.entities.Account;
import com.google.gerrit.entities.AccountGroup;
import com.google.gerrit.entities.InternalGroup;
-import com.google.gerrit.index.FieldDef;
+import com.google.gerrit.index.SchemaFieldDefs.SchemaField;
import com.google.gerrit.index.query.IndexPredicate;
import com.google.gerrit.index.query.Predicate;
import com.google.gerrit.server.index.group.GroupField;
@@ -26,7 +26,7 @@
/** Utility class to create predicates for group index queries. */
public class GroupPredicates {
public static Predicate<InternalGroup> id(AccountGroup.Id groupId) {
- return new GroupPredicate(GroupField.ID, groupId.toString());
+ return new GroupPredicate(GroupField.ID_FIELD_SPEC, groupId.toString());
}
public static Predicate<InternalGroup> uuid(AccountGroup.UUID uuid) {
@@ -35,42 +35,42 @@
public static Predicate<InternalGroup> description(String description) {
return new GroupPredicate(
- GroupField.DESCRIPTION, GroupQueryBuilder.FIELD_DESCRIPTION, description);
+ GroupField.DESCRIPTION_SPEC, GroupQueryBuilder.FIELD_DESCRIPTION, description);
}
public static Predicate<InternalGroup> inname(String name) {
return new GroupPredicate(
- GroupField.NAME_PART, GroupQueryBuilder.FIELD_INNAME, name.toLowerCase(Locale.US));
+ GroupField.NAME_PART_SPEC, GroupQueryBuilder.FIELD_INNAME, name.toLowerCase(Locale.US));
}
public static Predicate<InternalGroup> name(String name) {
- return new GroupPredicate(GroupField.NAME, name);
+ return new GroupPredicate(GroupField.NAME_SPEC, name);
}
public static Predicate<InternalGroup> owner(AccountGroup.UUID ownerUuid) {
return new GroupPredicate(
- GroupField.OWNER_UUID, GroupQueryBuilder.FIELD_OWNER, ownerUuid.get());
+ GroupField.OWNER_UUID_SPEC, GroupQueryBuilder.FIELD_OWNER, ownerUuid.get());
}
public static Predicate<InternalGroup> isVisibleToAll() {
- return new GroupPredicate(GroupField.IS_VISIBLE_TO_ALL, "1");
+ return new GroupPredicate(GroupField.IS_VISIBLE_TO_ALL_SPEC, "1");
}
public static Predicate<InternalGroup> member(Account.Id memberId) {
- return new GroupPredicate(GroupField.MEMBER, memberId.toString());
+ return new GroupPredicate(GroupField.MEMBER_SPEC, memberId.toString());
}
public static Predicate<InternalGroup> subgroup(AccountGroup.UUID subgroupUuid) {
- return new GroupPredicate(GroupField.SUBGROUP, subgroupUuid.get());
+ return new GroupPredicate(GroupField.SUBGROUP_SPEC, subgroupUuid.get());
}
/** Predicate that is mapped to a field in the group index. */
static class GroupPredicate extends IndexPredicate<InternalGroup> {
- GroupPredicate(FieldDef<InternalGroup, ?> def, String value) {
+ GroupPredicate(SchemaField<InternalGroup, ?> def, String value) {
super(def, value);
}
- GroupPredicate(FieldDef<InternalGroup, ?> def, String name, String value) {
+ GroupPredicate(SchemaField<InternalGroup, ?> def, String name, String value) {
super(def, name, value);
}
}
diff --git a/polygerrit-ui/app/api/rest-api.ts b/polygerrit-ui/app/api/rest-api.ts
index 5ec0836..ab33eed 100644
--- a/polygerrit-ui/app/api/rest-api.ts
+++ b/polygerrit-ui/app/api/rest-api.ts
@@ -407,7 +407,6 @@
cherry_pick_of_change?: NumericChangeId;
cherry_pick_of_patch_set?: RevisionPatchSetNum;
contains_git_conflicts?: boolean;
- internalHost?: string; // TODO(TS): provide an explanation what is its
submit_requirements?: SubmitRequirementResultInfo[];
submit_records?: SubmitRecordInfo[];
}
@@ -1180,3 +1179,19 @@
status: LabelStatus;
appliedBy: AccountInfo;
}
+
+/**
+ * Represent a file in a base64 encoding; GrRestApiInterface returns
+ * it from some methods
+ */
+export declare interface Base64FileContent {
+ content: string | null;
+ type: string | null;
+ ok: true;
+}
+
+export function isBase64FileContent(
+ res: Response | Base64FileContent
+): res is Base64FileContent {
+ return (res as Base64FileContent).ok;
+}
diff --git a/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section.ts b/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section.ts
index 4b62710..29f06fd 100644
--- a/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section.ts
+++ b/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section.ts
@@ -179,7 +179,7 @@
class=${this.section?.id === GLOBAL_NAME ? 'global' : ''}
@click=${this.editReference}
>
- <gr-icon id="icon" icon="edit"></gr-icon>
+ <gr-icon id="icon" icon="edit" filled small></gr-icon>
</gr-button>
</div>
<iron-input
diff --git a/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section_test.ts b/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section_test.ts
index 1048dde..b8cee66 100644
--- a/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section_test.ts
+++ b/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section_test.ts
@@ -84,7 +84,7 @@
role="button"
tabindex="0"
>
- <gr-icon icon="edit" id="icon"></gr-icon>
+ <gr-icon icon="edit" id="icon" small filled></gr-icon>
</gr-button>
</div>
<iron-input class="editRefInput">
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item.ts
index 8910e27..33129f6 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item.ts
@@ -477,20 +477,16 @@
ColumnNames.REPO,
this.visibleChangeTableColumns
)
- )
+ ) {
return;
+ }
+ const repo = this.change?.project ?? '';
return html`
<td class="cell repo">
- <a class="fullRepo" href=${this.computeRepoUrl()}>
- ${this.computeRepoDisplay()}
- </a>
- <a
- class="truncatedRepo"
- href=${this.computeRepoUrl()}
- title=${this.computeRepoDisplay()}
- >
- ${this.computeTruncatedRepoDisplay()}
+ <a class="fullRepo" href=${this.computeRepoUrl()}> ${repo} </a>
+ <a class="truncatedRepo" href=${this.computeRepoUrl()} title=${repo}>
+ ${truncatePath(repo, 2)}
</a>
</td>
`;
@@ -655,11 +651,7 @@
private computeRepoUrl() {
if (!this.change) return '';
- return GerritNav.getUrlForProjectChanges(
- this.change.project,
- true,
- this.change.internalHost
- );
+ return GerritNav.getUrlForProjectChanges(this.change.project, true);
}
private computeRepoBranchURL() {
@@ -667,36 +659,13 @@
return GerritNav.getUrlForBranch(
this.change.branch,
this.change.project,
- undefined,
- this.change.internalHost
+ undefined
);
}
private computeTopicURL() {
if (!this.change?.topic) return '';
- return GerritNav.getUrlForTopic(
- this.change.topic,
- this.change.internalHost
- );
- }
-
- /**
- * Computes the display string for the project column. If there is a host
- * specified in the change detail, the string will be prefixed with it.
- *
- * @param truncate whether or not the project name should be
- * truncated. If this value is truthy, the name will be truncated.
- *
- * private but used in test
- */
- computeRepoDisplay() {
- if (!this.change?.project) return '';
- let str = '';
- if (this.change.internalHost) {
- str += this.change.internalHost + '/';
- }
- str += this.change.project;
- return str;
+ return GerritNav.getUrlForTopic(this.change.topic);
}
private toggleCheckbox() {
@@ -706,19 +675,6 @@
}
// private but used in test
- computeTruncatedRepoDisplay() {
- if (!this.change?.project) {
- return '';
- }
- let str = '';
- if (this.change.internalHost) {
- str += this.change.internalHost + '/';
- }
- str += truncatePath(this.change.project, 2);
- return str;
- }
-
- // private but used in test
computeSizeTooltip() {
if (
!this.change ||
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item_test.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item_test.ts
index 50e6c2d..ed8c69f 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item_test.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item_test.ts
@@ -53,7 +53,6 @@
const account = createAccountWithId();
const change: ChangeInfo = {
...createChange(),
- internalHost: 'host',
project: 'a/test/repo' as RepoName,
topic: 'test-topic' as TopicName,
branch: 'test-branch' as BranchName,
@@ -335,18 +334,13 @@
assert.deepEqual(navStub.getUrlForProjectChanges.lastCall.args, [
change.project,
true,
- change.internalHost,
]);
assert.deepEqual(navStub.getUrlForBranch.lastCall.args, [
change.branch,
change.project,
undefined,
- change.internalHost,
]);
- assert.deepEqual(navStub.getUrlForTopic.lastCall.args, [
- change.topic,
- change.internalHost,
- ]);
+ assert.deepEqual(navStub.getUrlForTopic.lastCall.args, [change.topic!]);
});
test('clicking item navigates to change', async () => {
@@ -361,16 +355,6 @@
assert.isTrue(navStub.navigateToChange.calledWithExactly(change));
});
- test('computeRepoDisplay', () => {
- element.change = {...change};
- assert.equal(element.computeRepoDisplay(), 'host/a/test/repo');
- assert.equal(element.computeTruncatedRepoDisplay(), 'host/…/test/repo');
- delete change.internalHost;
- element.change = {...change};
- assert.equal(element.computeRepoDisplay(), 'a/test/repo');
- assert.equal(element.computeTruncatedRepoDisplay(), '…/test/repo');
- });
-
test('renders', async () => {
element.showStar = true;
element.showNumber = true;
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-reviewer-flow/gr-change-list-reviewer-flow_test.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-reviewer-flow/gr-change-list-reviewer-flow_test.ts
index 5a4a90c..ef48323 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-reviewer-flow/gr-change-list-reviewer-flow_test.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-reviewer-flow/gr-change-list-reviewer-flow_test.ts
@@ -891,8 +891,6 @@
);
assert.deepEqual(reviewerList.accounts[1], {
- _group: true,
- _pendingAdd: true,
confirmed: true,
id: '5' as GroupId,
name: 'large-group',
@@ -933,8 +931,6 @@
getComputedStyle(confirmDialog).getPropertyValue('display') === 'none'
);
assert.deepEqual(reviewerList.accounts[1], {
- _group: true,
- _pendingAdd: true,
id: '5' as GroupId,
name: 'small-group',
});
diff --git a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.ts b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.ts
index cfb5602..a9f57f5 100644
--- a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.ts
@@ -20,7 +20,6 @@
import '../gr-submit-requirements/gr-submit-requirements';
import '../gr-commit-info/gr-commit-info';
import '../gr-reviewer-list/gr-reviewer-list';
-import '../../shared/gr-account-list/gr-account-list';
import {GerritNav} from '../../core/gr-navigation/gr-navigation';
import {
ChangeStatus,
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog.ts b/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog.ts
index e6b9058..8bebf58 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog.ts
+++ b/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog.ts
@@ -231,34 +231,40 @@
Cherry Pick Change to Another Branch
</div>
<div class="main" slot="main">
- ${when(this.showCherryPickTopic, () =>
- this.renderCherrypickTopicLayout()
- )}
- <label for="branchInput"> Cherry Pick to branch </label>
- <gr-autocomplete
- id="branchInput"
- .text=${this.branch}
- .query=${this.query}
- placeholder="Destination branch"
- @text-changed=${(e: BindValueChangeEvent) =>
- (this.branch = e.detail.value as BranchName)}
- >
- </gr-autocomplete>
- ${when(
- this.invalidBranch,
- () => html`
- <span class="error"
- >Branch name cannot contain space or commas.</span
- >
- `
- )}
- ${choose(this.cherryPickType, [
- [
- CherryPickType.SINGLE_CHANGE,
- () => this.renderCherrypickSingleChangeInputs(),
- ],
- [CherryPickType.TOPIC, () => this.renderCherrypickTopicTable()],
- ])}
+ <gr-endpoint-decorator name="cherrypick-main">
+ <gr-endpoint-param name="changes" .value=${this.changes}>
+ </gr-endpoint-param>
+ <gr-endpoint-slot name="top"></gr-endpoint-slot>
+ ${when(this.showCherryPickTopic, () =>
+ this.renderCherrypickTopicLayout()
+ )}
+ <label for="branchInput"> Cherry Pick to branch </label>
+ <gr-autocomplete
+ id="branchInput"
+ .text=${this.branch}
+ .query=${this.query}
+ placeholder="Destination branch"
+ @text-changed=${(e: BindValueChangeEvent) =>
+ (this.branch = e.detail.value as BranchName)}
+ >
+ </gr-autocomplete>
+ ${when(
+ this.invalidBranch,
+ () => html`
+ <span class="error"
+ >Branch name cannot contain space or commas.</span
+ >
+ `
+ )}
+ ${choose(this.cherryPickType, [
+ [
+ CherryPickType.SINGLE_CHANGE,
+ () => this.renderCherrypickSingleChangeInputs(),
+ ],
+ [CherryPickType.TOPIC, () => this.renderCherrypickTopicTable()],
+ ])}
+ <gr-endpoint-slot name="bottom"></gr-endpoint-slot>
+ </gr-endpoint-decorator>
</div>
</gr-dialog>
`;
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog_test.ts b/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog_test.ts
index fbb6839..7933896 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog_test.ts
@@ -60,29 +60,34 @@
Cherry Pick Change to Another Branch
</div>
<div class="main" slot="main">
- <label for="branchInput"> Cherry Pick to branch </label>
- <gr-autocomplete id="branchInput" placeholder="Destination branch">
- </gr-autocomplete>
- <label for="baseInput">
- Provide base commit sha1 for cherry-pick
- </label>
- <iron-input>
- <input
- id="baseCommitInput"
- is="iron-input"
- maxlength="40"
- placeholder="(optional)"
- />
- </iron-input>
- <label for="messageInput"> Cherry Pick Commit Message </label>
- <iron-autogrow-textarea
- aria-disabled="false"
- autocomplete="on"
- class="message"
- id="messageInput"
- rows="4"
- >
- </iron-autogrow-textarea>
+ <gr-endpoint-decorator name="cherrypick-main">
+ <gr-endpoint-param name="changes"> </gr-endpoint-param>
+ <gr-endpoint-slot name="top"> </gr-endpoint-slot>
+ <label for="branchInput"> Cherry Pick to branch </label>
+ <gr-autocomplete id="branchInput" placeholder="Destination branch">
+ </gr-autocomplete>
+ <label for="baseInput">
+ Provide base commit sha1 for cherry-pick
+ </label>
+ <iron-input>
+ <input
+ id="baseCommitInput"
+ is="iron-input"
+ maxlength="40"
+ placeholder="(optional)"
+ />
+ </iron-input>
+ <label for="messageInput"> Cherry Pick Commit Message </label>
+ <iron-autogrow-textarea
+ aria-disabled="false"
+ autocomplete="on"
+ class="message"
+ id="messageInput"
+ rows="4"
+ >
+ </iron-autogrow-textarea>
+ <gr-endpoint-slot name="bottom"></gr-endpoint-slot>
+ </gr-endpoint-decorator>
</div>
</gr-dialog>
`);
diff --git a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.ts b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.ts
index 731bcd0..ccdcc15 100644
--- a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.ts
+++ b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.ts
@@ -27,15 +27,14 @@
} from '../../../constants/constants';
import {
accountOrGroupKey,
- isReviewerOrCC,
- mapReviewer,
+ isAccountNewlyAdded,
removeServiceUsers,
+ toReviewInput,
} from '../../../utils/account-util';
import {IronA11yAnnouncer} from '@polymer/iron-a11y-announcer/iron-a11y-announcer';
import {TargetElement} from '../../../api/plugin';
import {FixIronA11yAnnouncer} from '../../../types/types';
import {
- AccountAddition,
AccountInfoInput,
AccountInput,
AccountInputDetail,
@@ -71,6 +70,7 @@
areSetsEqual,
assertIsDefined,
containsAll,
+ difference,
queryAndAssert,
} from '../../../utils/common-util';
import {CommentThread, isUnresolved} from '../../../utils/comment-util';
@@ -108,6 +108,7 @@
import {customElement, property, state, query} from 'lit/decorators';
import {subscribe} from '../../lit/subscription-controller';
import {configModelToken} from '../../../models/config/config-model';
+import {hasHumanReviewer, isOwner} from '../../../utils/change-util';
const STORAGE_DEBOUNCE_INTERVAL_MS = 400;
@@ -321,9 +322,6 @@
@state()
isResolvedPatchsetLevelComment = true;
- @state()
- allReviewers: (AccountInfo | GroupInfo)[] = [];
-
private readonly restApiService: RestApiService =
getAppContext().restApiService;
@@ -493,6 +491,10 @@
}
.attention .edit-attention-button gr-icon {
color: inherit;
+ /* The line-height:26px hack (see below) requires us to do this.
+ Normally the gr-icon would account for a proper positioning
+ within the standard line-height:20px context. */
+ top: 5px;
}
.attention a,
.attention-detail a {
@@ -659,9 +661,6 @@
if (changedProperties.has('draftCommentThreads')) {
this.handleHeightChanged();
}
- if (changedProperties.has('reviewers')) {
- this.computeAllReviewers();
- }
if (changedProperties.has('sendDisabled')) {
this.sendDisabledChanged();
}
@@ -700,7 +699,7 @@
name="change"
.value=${this.change}
></gr-endpoint-param>
- <gr-endpoint-param name="reviewers" .value=${this.allReviewers}>
+ <gr-endpoint-param name="reviewers" .value=${[...this.reviewers]}>
</gr-endpoint-param>
${this.renderReviewerList()}
<gr-endpoint-slot name="below"></gr-endpoint-slot>
@@ -747,6 +746,7 @@
id="reviewers"
.accounts=${this.getAccountListCopy(this.reviewers)}
.change=${this.change}
+ .reviewerState=${ReviewerState.REVIEWER}
@account-added=${this.accountAdded}
@accounts-changed=${this.handleReviewersChanged}
.removableValues=${this.change?.removable_reviewers}
@@ -773,6 +773,8 @@
<gr-account-list
id="ccs"
.accounts=${this.getAccountListCopy(this.ccs)}
+ .change=${this.change}
+ .reviewerState=${ReviewerState.CC}
@account-added=${this.accountAdded}
@accounts-changed=${this.handleCcsChanged}
.removableValues=${this.change?.removable_reviewers}
@@ -969,8 +971,10 @@
role="button"
tabindex="0"
>
- <gr-icon icon="edit" filled></gr-icon>
- Modify
+ <div>
+ <gr-icon icon="edit" filled small></gr-icon>
+ <span>Modify</span>
+ </div>
</gr-button>
</gr-tooltip-content>
</div>
@@ -1047,7 +1051,7 @@
<div class="peopleList">
<div class="peopleListLabel">Reviewers</div>
<div class="peopleListValues">
- ${this.removeServiceUsers(this.reviewers).map(
+ ${removeServiceUsers(this.reviewers).map(
account => html`
<gr-account-label
.account=${account}
@@ -1069,7 +1073,7 @@
<div class="peopleList">
<div class="peopleListLabel">CC</div>
<div class="peopleListValues">
- ${this.removeServiceUsers(this.ccs).map(
+ ${removeServiceUsers(this.ccs).map(
account => html`
<gr-account-label
.account=${account}
@@ -1281,42 +1285,32 @@
return isResolvedPatchsetLevelComment ? 'resolved' : 'unresolved';
}
- computeReviewers(change: ChangeInfo) {
+ computeReviewers() {
const reviewers: ReviewerInput[] = [];
- const addToReviewInput = (
- additions: AccountAddition[],
- state?: ReviewerState
- ) => {
- additions.forEach(addition => {
- const reviewer = mapReviewer(addition);
- if (state) reviewer.state = state;
- reviewers.push(reviewer);
- });
- };
- addToReviewInput(this.reviewersList!.additions(), ReviewerState.REVIEWER);
- addToReviewInput(this.ccsList!.additions(), ReviewerState.CC);
- addToReviewInput(
- this.reviewersList!.removals().filter(
- r =>
- isReviewerOrCC(change, r) &&
- // ignore removal from reviewer request if being added to CC
- !this.ccsList!.additions().some(
- account => mapReviewer(account).reviewer === mapReviewer(r).reviewer
- )
- ),
- ReviewerState.REMOVED
+ const reviewerAdditions = this.reviewersList?.additions() ?? [];
+ reviewers.push(
+ ...reviewerAdditions.map(v => toReviewInput(v, ReviewerState.REVIEWER))
);
- addToReviewInput(
- this.ccsList!.removals().filter(
- r =>
- isReviewerOrCC(change, r) &&
- // ignore removal from CC request if being added as reviewer
- !this.reviewersList!.additions().some(
- account => mapReviewer(account).reviewer === mapReviewer(r).reviewer
- )
- ),
- ReviewerState.REMOVED
- );
+
+ const ccAdditions = this.ccsList?.additions() ?? [];
+ reviewers.push(...ccAdditions.map(v => toReviewInput(v, ReviewerState.CC)));
+
+ // ignore removal from reviewer request if being added as CC
+ let removals = difference(
+ this.reviewersList?.removals() ?? [],
+ ccAdditions,
+ (a, b) => accountOrGroupKey(a) === accountOrGroupKey(b)
+ ).map(v => toReviewInput(v, ReviewerState.REMOVED));
+ reviewers.push(...removals);
+
+ // ignore removal from CC request if being added as reviewer
+ removals = difference(
+ this.ccsList?.removals() ?? [],
+ reviewerAdditions,
+ (a, b) => accountOrGroupKey(a) === accountOrGroupKey(b)
+ ).map(v => toReviewInput(v, ReviewerState.REMOVED));
+ reviewers.push(...removals);
+
return reviewers;
}
@@ -1367,7 +1361,7 @@
}
assertIsDefined(this.change, 'change');
- reviewInput.reviewers = this.computeReviewers(this.change);
+ reviewInput.reviewers = this.computeReviewers();
this.disabled = true;
const errFn = (r?: Response | null) => this.handle400Error(r);
@@ -1422,19 +1416,9 @@
}
chooseFocusTarget() {
- // If we are the owner and the reviewers field is empty, focus on that.
- if (
- this.account &&
- this.change &&
- this.change.owner &&
- this.account._account_id === this.change.owner._account_id &&
- (!this.reviewers || this.reviewers?.length === 0)
- ) {
- return FocusTarget.REVIEWERS;
- }
-
- // Default to BODY.
- return FocusTarget.BODY;
+ if (!isOwner(this.change, this.account)) return FocusTarget.BODY;
+ if (hasHumanReviewer(this.change)) return FocusTarget.BODY;
+ return FocusTarget.REVIEWERS;
}
isOwner(account?: AccountInfo, change?: ChangeInfo) {
@@ -1624,7 +1608,11 @@
);
this.reviewers
.filter(r => isAccount(r))
- .filter(r => r._pendingAdd || (this.canBeStarted && isOwner))
+ .filter(
+ r =>
+ isAccountNewlyAdded(r, ReviewerState.REVIEWER, this.change) ||
+ (this.canBeStarted && isOwner)
+ )
.filter(notIsReviewerAndHasDraftOrLabel)
.forEach(r => newAttention.add((r as AccountInfo)._account_id!));
// Add owner and uploader, if someone else replies.
@@ -1743,10 +1731,6 @@
return removeServiceUsers(allAccounts.filter(isAccount));
}
- removeServiceUsers(accounts: AccountInfo[]) {
- return removeServiceUsers(accounts);
- }
-
computeUploader() {
if (
!this.change?.current_revision ||
@@ -1816,7 +1800,6 @@
})
);
queryAndAssert<GrTextarea>(this, 'gr-textarea').closeDropdown();
- this.reviewersList?.clearPendingRemovals();
this.rebuildReviewerArrays();
}
@@ -2102,10 +2085,6 @@
}
this.reporting.reportInteraction('attention-set-actions', {actions});
}
-
- computeAllReviewers() {
- this.allReviewers = [...this.reviewers];
- }
}
declare global {
diff --git a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.ts b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.ts
index 95cdba91..67190de 100644
--- a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.ts
@@ -28,6 +28,7 @@
createCommentThread,
createDraft,
createRevision,
+ createServiceUserWithId,
} from '../../../test/test-data-generators';
import {
pressAndReleaseKeyOn,
@@ -281,8 +282,10 @@
role="button"
tabindex="0"
>
- <gr-icon icon="edit" filled></gr-icon>
- Modify
+ <div>
+ <gr-icon icon="edit" filled small></gr-icon>
+ <span>Modify</span>
+ </div>
</gr-button>
</gr-tooltip-content>
</div>
@@ -897,8 +900,8 @@
await element.updateComplete;
element.reviewers = [
- {_account_id: 1 as AccountId, _pendingAdd: true},
- {_account_id: 2 as AccountId, _pendingAdd: true},
+ {_account_id: 1 as AccountId},
+ {_account_id: 2 as AccountId},
];
element.ccs = [];
element.draftCommentThreads = [];
@@ -927,6 +930,12 @@
owner: {_account_id: 1 as AccountId},
status: ChangeStatus.NEW,
attention_set: {},
+ reviewers: {
+ [ReviewerState.REVIEWER]: [
+ {...createAccountWithId(2)},
+ {...createAccountWithId(3)},
+ ],
+ },
};
// let rebuildReviewers triggered by change update finish running
await element.updateComplete;
@@ -1340,14 +1349,8 @@
);
// No reviewer/CC should have been added.
- assert.equal(
- queryAndAssert<GrAccountList>(element, '#ccs').additions().length,
- 0
- );
- assert.equal(
- queryAndAssert<GrAccountList>(element, '#reviewers').additions().length,
- 0
- );
+ assert.equal(element.ccsList?.additions().length, 0);
+ assert.equal(element.reviewersList?.additions().length, 0);
// Reopen confirmation dialog.
observer = overlayObserver('opened');
@@ -1377,17 +1380,11 @@
isVisible(queryAndAssert(element, 'reviewerConfirmationOverlay'))
);
const additions = cc
- ? queryAndAssert<GrAccountList>(element, '#ccs').additions()
- : queryAndAssert<GrAccountList>(element, '#reviewers').additions();
+ ? element.ccsList?.additions()
+ : element.reviewersList?.additions();
assert.deepEqual(additions, [
{
- group: {
- id: 'id' as GroupId,
- name: 'name' as GroupName,
- confirmed: true,
- _group: true,
- _pendingAdd: true,
- },
+ name: 'name' as GroupName,
},
]);
@@ -1628,28 +1625,22 @@
test('chooseFocusTarget', () => {
element.account = undefined;
- assert.strictEqual(element.chooseFocusTarget(), element.FocusTarget.BODY);
+ assert.equal(element.chooseFocusTarget(), element.FocusTarget.BODY);
- element.account = {_account_id: 1 as AccountId};
- assert.strictEqual(element.chooseFocusTarget(), element.FocusTarget.BODY);
+ element.account = element.change!.owner;
+ assert.equal(element.chooseFocusTarget(), element.FocusTarget.REVIEWERS);
- element.change!.owner = {_account_id: 2 as AccountId};
- assert.strictEqual(element.chooseFocusTarget(), element.FocusTarget.BODY);
+ element.change!.reviewers.REVIEWER = [createAccountWithId(314)];
+ assert.equal(element.chooseFocusTarget(), element.FocusTarget.BODY);
- element.change!.owner._account_id = 1 as AccountId;
- assert.strictEqual(
- element.chooseFocusTarget(),
- element.FocusTarget.REVIEWERS
- );
+ element.change!.reviewers.REVIEWER = [createServiceUserWithId(314)];
+ assert.equal(element.chooseFocusTarget(), element.FocusTarget.REVIEWERS);
- element.reviewers = [];
- assert.strictEqual(
- element.chooseFocusTarget(),
- element.FocusTarget.REVIEWERS
- );
-
- element.reviewers.push({});
- assert.strictEqual(element.chooseFocusTarget(), element.FocusTarget.BODY);
+ element.change!.reviewers.REVIEWER = [
+ createAccountWithId(314),
+ createServiceUserWithId(314),
+ ];
+ assert.equal(element.chooseFocusTarget(), element.FocusTarget.BODY);
});
test('only send labels that have changed', async () => {
@@ -2003,6 +1994,8 @@
[ReviewerState.REVIEWER]: [{_account_id: reviewer1._account_id}],
};
+ await element.updateComplete;
+
const mutations: ReviewerInput[] = [];
stubSaveReview((review: ReviewInput) => {
diff --git a/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list.ts b/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list.ts
index 61e16b0..163ec49 100644
--- a/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list.ts
+++ b/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list.ts
@@ -73,15 +73,14 @@
}
.addReviewer gr-icon {
color: inherit;
- font-size: 18px;
}
.controlsContainer {
display: inline-block;
}
gr-button.addReviewer {
- --gr-button-padding: 1px 0px;
vertical-align: top;
- top: 1px;
+ --gr-button-padding: var(--spacing-s);
+ --margin: calc(0px - var(--spacing-s));
}
gr-button {
line-height: var(--line-height-normal);
@@ -117,7 +116,10 @@
class="addReviewer"
@click=${this.handleAddTap}
title=${this.ccsOnly ? 'Add CC' : 'Add reviewer'}
- ><gr-icon icon="edit" filled></gr-icon>
+ >
+ <div>
+ <gr-icon icon="edit" filled small></gr-icon>
+ </div>
</gr-button>
</div>
</div>
diff --git a/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list_test.ts b/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list_test.ts
index 89bb2fd..9f8aada 100644
--- a/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list_test.ts
@@ -39,7 +39,9 @@
tabindex="0"
title="Add reviewer"
>
- <gr-icon icon="edit" filled></gr-icon>
+ <div>
+ <gr-icon icon="edit" filled small></gr-icon>
+ </div>
</gr-button>
</div>
</div>
diff --git a/polygerrit-ui/app/elements/checks/gr-checks-action.ts b/polygerrit-ui/app/elements/checks/gr-checks-action.ts
index 3564ec9..d7373de 100644
--- a/polygerrit-ui/app/elements/checks/gr-checks-action.ts
+++ b/polygerrit-ui/app/elements/checks/gr-checks-action.ts
@@ -6,7 +6,7 @@
import {LitElement, css, html} from 'lit';
import {customElement, property} from 'lit/decorators';
import {Action} from '../../api/checks';
-import {checkRequiredProperty} from '../../utils/common-util';
+import {assertIsDefined} from '../../utils/common-util';
import {resolve} from '../../models/dependency';
import {checksModelToken} from '../../models/checks/checks-model';
@customElement('gr-checks-action')
@@ -25,7 +25,7 @@
override connectedCallback() {
super.connectedCallback();
- checkRequiredProperty(this.action, 'action');
+ assertIsDefined(this.action, 'action');
}
static override get styles() {
diff --git a/polygerrit-ui/app/elements/checks/gr-checks-results.ts b/polygerrit-ui/app/elements/checks/gr-checks-results.ts
index 202c502..edefdd1 100644
--- a/polygerrit-ui/app/elements/checks/gr-checks-results.ts
+++ b/polygerrit-ui/app/elements/checks/gr-checks-results.ts
@@ -33,7 +33,7 @@
secondaryLinks,
tooltipForLink,
} from '../../models/checks/checks-util';
-import {assertIsDefined, check} from '../../utils/common-util';
+import {assertIsDefined, assert} from '../../utils/common-util';
import {modifierPressed, toggleClass, whenVisible} from '../../utils/dom-util';
import {durationString} from '../../utils/date-util';
import {charsOnly} from '../../utils/string-util';
@@ -1201,7 +1201,7 @@
private onPatchsetSelected(e: CustomEvent<{value: string}>) {
const patchset = Number(e.detail.value);
- check(!isNaN(patchset), 'selected patchset must be a number');
+ assert(!isNaN(patchset), 'selected patchset must be a number');
this.getChecksModel().setPatchset(patchset as PatchSetNumber);
}
diff --git a/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation.ts b/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation.ts
index 3942086..63338db 100644
--- a/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation.ts
+++ b/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation.ts
@@ -157,7 +157,6 @@
// TODO(TS): Define more precise type (enum?)
statuses?: string[];
hashtag?: string;
- host?: string;
owner?: string;
}
@@ -169,7 +168,6 @@
patchNum?: RevisionPatchSetNum;
basePatchNum?: BasePatchSetNum;
edit?: boolean;
- host?: string;
messageHash?: string;
commentId?: UrlEncodedCommentId;
forceReload?: boolean;
@@ -456,49 +454,34 @@
/**
* @param openOnly When true, only search open changes in the project.
- * @param host The host in which to search.
*/
- getUrlForProjectChanges(
- project: RepoName,
- openOnly?: boolean,
- host?: string
- ) {
+ getUrlForProjectChanges(project: RepoName, openOnly?: boolean) {
return this._getUrlFor({
view: GerritView.SEARCH,
project,
statuses: openOnly ? ['open'] : [],
- host,
});
},
/**
* @param status The status to search.
- * @param host The host in which to search.
*/
- getUrlForBranch(
- branch: BranchName,
- project: RepoName,
- status?: string,
- host?: string
- ) {
+ getUrlForBranch(branch: BranchName, project: RepoName, status?: string) {
return this._getUrlFor({
view: GerritView.SEARCH,
branch,
project,
statuses: status ? [status] : undefined,
- host,
});
},
/**
* @param topic The name of the topic.
- * @param host The host in which to search.
*/
- getUrlForTopic(topic: TopicName, host?: string) {
+ getUrlForTopic(topic: TopicName) {
return this._getUrlFor({
view: GerritView.SEARCH,
topic,
- host,
});
},
@@ -543,7 +526,7 @@
* @param basePatchNum The string PARENT can be used for none.
*/
getUrlForChange(
- change: Pick<ChangeInfo, '_number' | 'project' | 'internalHost'>,
+ change: Pick<ChangeInfo, '_number' | 'project'>,
options: ChangeUrlParams = {}
) {
let {
@@ -567,7 +550,6 @@
patchNum,
basePatchNum,
edit: isEdit,
- host: change.internalHost || undefined,
messageHash,
forceReload,
openReplyDialog,
@@ -600,7 +582,7 @@
* and re-render everything.
*/
navigateToChange(
- change: Pick<ChangeInfo, '_number' | 'project' | 'internalHost'>,
+ change: Pick<ChangeInfo, '_number' | 'project'>,
options: NavigateToChangeParams = {}
) {
const {
diff --git a/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog.ts b/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog.ts
index 5296f13..6e48231 100644
--- a/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog.ts
+++ b/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog.ts
@@ -12,15 +12,22 @@
import {
NumericChangeId,
EDIT,
- FixId,
FixSuggestionInfo,
PatchSetNum,
RobotId,
BasePatchSetNum,
+ FilePathToDiffInfoMap,
} from '../../../types/common';
import {DiffInfo, DiffPreferencesInfo} from '../../../types/diff';
import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
-import {isRobot} from '../../../utils/comment-util';
+import {
+ createUserFixSuggestion,
+ getContentInCommentRange,
+ getUserSuggestion,
+ hasUserSuggestion,
+ isRobot,
+ USER_SUGGEST_EDIT_FIX_ID,
+} from '../../../utils/comment-util';
import {OpenFixPreviewEvent} from '../../../types/events';
import {getAppContext} from '../../../services/app-context';
import {fireCloseFixPreview, fireEvent} from '../../../utils/event-util';
@@ -31,6 +38,8 @@
import {customElement, property, query, state} from 'lit/decorators';
import {sharedStyles} from '../../../styles/shared-styles';
import {subscribe} from '../../lit/subscription-controller';
+import {isBase64FileContent} from '../../../api/rest-api';
+import {assertIsDefined} from '../../../utils/common-util';
interface FilePreview {
filepath: string;
@@ -210,15 +219,32 @@
* @return Promise that resolves either when all
* preview diffs are fetched or no fix suggestions in custom event detail.
*/
- open(e: OpenFixPreviewEvent) {
+ async open(e: OpenFixPreviewEvent) {
const detail = e.detail;
const comment = detail.comment;
- if (!detail.patchNum || !comment || !isRobot(comment)) {
- return Promise.resolve();
+ if (comment && hasUserSuggestion(comment)) {
+ const replacement = getUserSuggestion(comment);
+ if (!replacement) throw new Error('User suggestion is malformatted');
+ // TODO(milutin): This should belongs into service/model.
+ const file = await this.restApiService.getFileContent(
+ this.changeNum!,
+ comment.path!,
+ comment.patch_set!
+ );
+ assertIsDefined(file, 'file');
+ if (!isBase64FileContent(file))
+ throw new Error('Cannot retrieve file content');
+ assertIsDefined(file.content, 'content');
+ const line = getContentInCommentRange(file.content, comment);
+ this.fixSuggestions = createUserFixSuggestion(comment, line, replacement);
+ } else {
+ if (!detail.patchNum || !comment || !isRobot(comment)) {
+ return Promise.resolve();
+ }
+ this.fixSuggestions = comment.fix_suggestions;
+ this.robotId = comment.robot_id;
}
this.patchNum = detail.patchNum;
- this.fixSuggestions = comment.fix_suggestions;
- this.robotId = comment.robot_id;
if (!this.fixSuggestions || !this.fixSuggestions.length) {
return Promise.resolve();
}
@@ -233,30 +259,42 @@
});
}
- private showSelectedFixSuggestion(fixSuggestion: FixSuggestionInfo) {
+ private async showSelectedFixSuggestion(fixSuggestion: FixSuggestionInfo) {
this.currentFix = fixSuggestion;
- return this.fetchFixPreview(fixSuggestion.fix_id);
+ await this.fetchFixPreview(fixSuggestion);
}
- private fetchFixPreview(fixId: FixId) {
+ private async fetchFixPreview(fixSuggestion: FixSuggestionInfo) {
if (!this.changeNum || !this.patchNum) {
return Promise.reject(
new Error('Both patchNum and changeNum must be set')
);
}
- return this.restApiService
- .getRobotCommentFixPreview(this.changeNum, this.patchNum, fixId)
- .then(res => {
- if (res) {
- this.currentPreviews = Object.keys(res).map(key => {
- return {filepath: key, preview: res[key]};
- });
- }
- })
- .catch(err => {
- this.close(false);
- throw err;
- });
+ let res: FilePathToDiffInfoMap | undefined;
+ try {
+ if (fixSuggestion.fix_id === USER_SUGGEST_EDIT_FIX_ID) {
+ res = await this.restApiService.getFixPreview(
+ this.changeNum,
+ this.patchNum,
+ fixSuggestion.replacements
+ );
+ } else {
+ res = await this.restApiService.getRobotCommentFixPreview(
+ this.changeNum,
+ this.patchNum,
+ fixSuggestion.fix_id
+ );
+ }
+ if (res) {
+ this.currentPreviews = Object.keys(res).map(key => {
+ return {filepath: key, preview: res![key]};
+ });
+ }
+ } catch (e) {
+ this.close(false);
+ throw e;
+ }
+ return res;
}
private overridePartialDiffPrefs() {
@@ -328,11 +366,20 @@
throw new Error('Not all required properties are set.');
}
this.isApplyFixLoading = true;
- const res = await this.restApiService.applyFixSuggestion(
- changeNum,
- patchNum,
- this.currentFix.fix_id
- );
+ let res;
+ if (this.fixSuggestions?.[0].fix_id === USER_SUGGEST_EDIT_FIX_ID) {
+ res = await this.restApiService.applyFixSuggestion(
+ changeNum,
+ patchNum,
+ this.fixSuggestions[0].replacements
+ );
+ } else {
+ res = await this.restApiService.applyRobotFixSuggestion(
+ changeNum,
+ patchNum,
+ this.currentFix.fix_id
+ );
+ }
if (res && res.ok) {
GerritNav.navigateToChange(change, {
patchNum: EDIT,
diff --git a/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog_test.ts b/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog_test.ts
index e29731b..8d2b81e 100644
--- a/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog_test.ts
+++ b/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog_test.ts
@@ -256,9 +256,9 @@
});
test('apply fix button should call apply, navigate to change view and fire close', async () => {
- const applyFixSuggestionStub = stubRestApi('applyFixSuggestion').returns(
- Promise.resolve(new Response(null, {status: 200}))
- );
+ const applyRobotFixSuggestionStub = stubRestApi(
+ 'applyRobotFixSuggestion'
+ ).returns(Promise.resolve(new Response(null, {status: 200})));
const navigateToChangeStub = sinon.stub(GerritNav, 'navigateToChange');
element.currentFix = createFixSuggestionInfo('123');
@@ -271,7 +271,7 @@
await element.handleApplyFix(new CustomEvent('confirm'));
sinon.assert.calledOnceWithExactly(
- applyFixSuggestionStub,
+ applyRobotFixSuggestionStub,
element.change!._number,
2 as PatchSetNum,
'123'
@@ -296,15 +296,15 @@
});
test('should not navigate to change view if incorect reponse', async () => {
- const applyFixSuggestionStub = stubRestApi('applyFixSuggestion').returns(
- Promise.resolve(new Response(null, {status: 500}))
- );
+ const applyRobotFixSuggestionStub = stubRestApi(
+ 'applyRobotFixSuggestion'
+ ).returns(Promise.resolve(new Response(null, {status: 500})));
const navigateToChangeStub = sinon.stub(GerritNav, 'navigateToChange');
element.currentFix = createFixSuggestionInfo('fix_123');
await element.handleApplyFix(new CustomEvent('confirm'));
sinon.assert.calledWithExactly(
- applyFixSuggestionStub,
+ applyRobotFixSuggestionStub,
element.change!._number,
2 as PatchSetNum,
'fix_123'
@@ -325,7 +325,7 @@
});
test('server-error should throw for failed apply call', async () => {
- stubRestApi('applyFixSuggestion').returns(
+ stubRestApi('applyRobotFixSuggestion').returns(
Promise.reject(new Error('backend error'))
);
const navigateToChangeStub = sinon.stub(GerritNav, 'navigateToChange');
diff --git a/polygerrit-ui/app/elements/gr-app.ts b/polygerrit-ui/app/elements/gr-app.ts
index f63b50e..119cd5a 100644
--- a/polygerrit-ui/app/elements/gr-app.ts
+++ b/polygerrit-ui/app/elements/gr-app.ts
@@ -67,7 +67,8 @@
}
if (!this.serviceWorkerInstaller) {
this.serviceWorkerInstaller = new ServiceWorkerInstaller(
- appContext.flagsService
+ appContext.flagsService,
+ appContext.userModel
);
this.serviceWorkerInstaller.init();
}
diff --git a/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label.ts b/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label.ts
index 5c0c6d6..9e27013 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label.ts
+++ b/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label.ts
@@ -155,11 +155,12 @@
/* This negates the 4px horizontal padding, which we appreciate as a
larger click target, but which we don't want to consume space. :-) */
margin: 0 -4px 0 -4px;
+ --gr-button-padding: 0 var(--spacing-xs);
vertical-align: top;
}
gr-icon.attention {
color: var(--deemphasized-text-color);
- font-size: 12px;
+ transform: scaleX(0.8);
}
.name {
display: inline-block;
@@ -235,11 +236,16 @@
this.selected,
this._selfAccount
)}
- ><gr-icon
- icon="label_important"
- filled
- class="attention"
- ></gr-icon>
+ >
+ <div>
+ <gr-icon
+ icon="label_important"
+ filled
+ small
+ class="attention"
+ >
+ </gr-icon>
+ </div>
</gr-button>
</gr-tooltip-content>`
: ''}
diff --git a/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list.ts b/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list.ts
index 1fa21e5..c8a2995 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list.ts
+++ b/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list.ts
@@ -15,12 +15,16 @@
SuggestedReviewerGroupInfo,
SuggestedReviewerAccountInfo,
SuggestedReviewerInfo,
+ isGroup,
} from '../../../types/common';
import {ReviewerSuggestionsProvider} from '../../../scripts/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider';
import {GrAccountEntry} from '../gr-account-entry/gr-account-entry';
import {GrAccountChip} from '../gr-account-chip/gr-account-chip';
import {fire, fireAlert} from '../../../utils/event-util';
-import {accountOrGroupKey} from '../../../utils/account-util';
+import {
+ accountOrGroupKey,
+ isAccountNewlyAdded,
+} from '../../../utils/account-util';
import {LitElement, css, html, PropertyValues} from 'lit';
import {customElement, property, query, state} from 'lit/decorators';
import {sharedStyles} from '../../../styles/shared-styles';
@@ -31,9 +35,10 @@
GrAutocomplete,
} from '../gr-autocomplete/gr-autocomplete';
import {ValueChangedEvent} from '../../../types/events';
-import {queryAndAssert} from '../../../utils/common-util';
+import {difference, queryAndAssert} from '../../../utils/common-util';
import {PaperInputElement} from '@polymer/paper-input/paper-input';
import {IronInputElement} from '@polymer/iron-input';
+import {ReviewerState} from '../../../api/rest-api';
const VALID_EMAIL_ALERT = 'Please input a valid email.';
@@ -74,37 +79,18 @@
// Internal input type with account info
export interface AccountInfoInput extends AccountInfo {
- _group?: boolean;
_account?: boolean;
- _pendingAdd?: boolean;
confirmed?: boolean;
}
// Internal input type with group info
export interface GroupInfoInput extends GroupInfo {
- _group?: boolean;
_account?: boolean;
- _pendingAdd?: boolean;
confirmed?: boolean;
}
-function isAccountInfoInput(x: AccountInput): x is AccountInfoInput {
- const input = x as AccountInfoInput;
- return !!input._account || !!input._account_id || !!input.email;
-}
-
-function isGroupInfoInput(x: AccountInput): x is GroupInfoInput {
- const input = x as GroupInfoInput;
- return !!input._group || !!input.id;
-}
-
export type AccountInput = AccountInfoInput | GroupInfoInput;
-export interface AccountAddition {
- account?: AccountInfoInput;
- group?: GroupInfoInput;
-}
-
@customElement('gr-account-list')
export class GrAccountList extends LitElement {
/**
@@ -129,6 +115,9 @@
@property({type: Boolean})
disabled = false;
+ @property({type: String})
+ reviewerState?: ReviewerState;
+
/**
* Returns suggestions and convert them to list item
*/
@@ -164,8 +153,6 @@
private readonly reporting = getAppContext().reportingService;
- private pendingRemoval: Set<AccountInput> = new Set();
-
constructor() {
super();
this.querySuggestions = input => this.getSuggestions(input);
@@ -190,7 +177,7 @@
.group {
--account-label-suffix: ' (group)';
}
- .pendingAdd {
+ .newlyAdded {
font-style: italic;
}
.list {
@@ -209,8 +196,12 @@
.account=${account}
.change=${this.change}
class=${classMap({
- group: !!account._group,
- pendingAdd: !!account._pendingAdd,
+ group: isGroup(account),
+ newlyAdded: isAccountNewlyAdded(
+ account,
+ this.reviewerState,
+ this.change
+ ),
})}
?removable=${this.computeRemovable(account)}
@keydown=${this.handleChipKeydown}
@@ -284,8 +275,7 @@
let group;
let itemTypeAdded = 'unknown';
if (isAccountObject(item)) {
- account = {...item.account, _pendingAdd: true};
- this.removeFromPendingRemoval(account);
+ account = {...item.account};
this.accounts.push(account);
itemTypeAdded = 'account';
} else if (isSuggestedReviewerGroupInfo(item)) {
@@ -293,9 +283,8 @@
this.pendingConfirmation = item;
return;
}
- group = {...item.group, _pendingAdd: true, _group: true};
+ group = {...item.group};
this.accounts.push(group);
- this.removeFromPendingRemoval(group);
itemTypeAdded = 'group';
} else if (this.allowAnyInput) {
if (!item.includes('@')) {
@@ -305,9 +294,8 @@
fireAlert(this, VALID_EMAIL_ALERT);
return false;
} else {
- account = {email: item as EmailAddress, _pendingAdd: true};
+ account = {email: item as EmailAddress};
this.accounts.push(account);
- this.removeFromPendingRemoval(account);
itemTypeAdded = 'email';
}
}
@@ -323,8 +311,6 @@
this.accounts.push({
...group,
confirmed: true,
- _pendingAdd: true,
- _group: true,
});
this.pendingConfirmation = null;
fire(this, 'accounts-changed', {value: this.accounts});
@@ -332,18 +318,6 @@
}
// private but used in test
- computeChipClass(account: AccountInput) {
- const classes = [];
- if (account._group) {
- classes.push('group');
- }
- if (account._pendingAdd) {
- classes.push('pendingAdd');
- }
- return classes.join(' ');
- }
-
- // private but used in test
computeRemovable(account: AccountInput) {
if (this.readonly) {
return false;
@@ -357,7 +331,7 @@
return true;
}
}
- return !!account._pendingAdd;
+ return isAccountNewlyAdded(account, this.reviewerState, this.change);
}
return true;
}
@@ -375,7 +349,6 @@
for (let i = 0; i < this.accounts.length; i++) {
if (accountOrGroupKey(toRemove) === accountOrGroupKey(this.accounts[i])) {
this.accounts.splice(i, 1);
- this.pendingRemoval.add(toRemove);
this.reporting.reportInteraction(`Remove from ${this.id}`);
this.requestUpdate();
fire(this, 'accounts-changed', {value: this.accounts.slice()});
@@ -473,37 +446,19 @@
return wasSubmitted;
}
- additions(): AccountAddition[] {
- return this.accounts
- .filter(account => account._pendingAdd)
- .map(account => {
- if (isGroupInfoInput(account)) {
- return {group: account};
- } else if (isAccountInfoInput(account)) {
- return {account};
- } else {
- throw new Error('AccountInput must be either Account or Group.');
- }
- });
+ additions(): (AccountInfoInput | GroupInfoInput)[] {
+ if (!this.change) return [];
+ return this.accounts.filter(account =>
+ isAccountNewlyAdded(account, this.reviewerState, this.change)
+ );
}
- removals(): AccountAddition[] {
- return Array.from(this.pendingRemoval).map(account => {
- if (isGroupInfoInput(account)) {
- return {group: account};
- } else if (isAccountInfoInput(account)) {
- return {account};
- } else {
- throw new Error('AccountInput must be either Account or Group.');
- }
- });
- }
-
- private removeFromPendingRemoval(account: AccountInput) {
- this.pendingRemoval.delete(account);
- }
-
- clearPendingRemovals() {
- this.pendingRemoval.clear();
+ removals(): AccountInfo[] {
+ if (!this.reviewerState) return [];
+ return difference(
+ this.change?.reviewers[this.reviewerState] ?? [],
+ this.accounts,
+ (a, b) => accountOrGroupKey(a) === accountOrGroupKey(b)
+ );
}
}
diff --git a/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list_test.ts b/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list_test.ts
index 21b9e59..873b6cf 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list_test.ts
@@ -28,6 +28,8 @@
GrAutocomplete,
} from '../gr-autocomplete/gr-autocomplete';
import {GrAccountEntry} from '../gr-account-entry/gr-account-entry';
+import {createChange} from '../../../test/test-data-generators';
+import {ReviewerState} from '../../../api/rest-api';
const basicFixture = fixtureFromElement('gr-account-list');
@@ -65,7 +67,6 @@
const groupId = `group${++_nextAccountId}`;
return {
id: groupId as GroupId,
- _group: true,
name: 'abcd' as GroupName,
};
};
@@ -94,6 +95,9 @@
element = basicFixture.instantiate();
element.accounts = [existingAccount1, existingAccount2];
+ element.reviewerState = ReviewerState.REVIEWER;
+ element.change = {...createChange()};
+ element.change.reviewers[ReviewerState.REVIEWER] = [...element.accounts];
suggestionsProvider = new MockSuggestionsProvider();
element.suggestionsProvider = suggestionsProvider;
await element.updateComplete;
@@ -130,18 +134,18 @@
// Existing accounts are listed.
let chips = getChips();
assert.equal(chips.length, 2);
- assert.isFalse(chips[0].classList.contains('pendingAdd'));
- assert.isFalse(chips[1].classList.contains('pendingAdd'));
+ assert.isFalse(chips[0].classList.contains('newlyAdded'));
+ assert.isFalse(chips[1].classList.contains('newlyAdded'));
- // New accounts are added to end with pendingAdd class.
+ // New accounts are added to end with newlyAdded class.
const newAccount = makeAccount();
handleAdd({account: newAccount, count: 1});
await element.updateComplete;
chips = getChips();
assert.equal(chips.length, 3);
- assert.isFalse(chips[0].classList.contains('pendingAdd'));
- assert.isFalse(chips[1].classList.contains('pendingAdd'));
- assert.isTrue(chips[2].classList.contains('pendingAdd'));
+ assert.isFalse(chips[0].classList.contains('newlyAdded'));
+ assert.isFalse(chips[1].classList.contains('newlyAdded'));
+ assert.isTrue(chips[2].classList.contains('newlyAdded'));
// Removed accounts are taken out of the list.
element.dispatchEvent(
@@ -154,8 +158,8 @@
await element.updateComplete;
chips = getChips();
assert.equal(chips.length, 2);
- assert.isFalse(chips[0].classList.contains('pendingAdd'));
- assert.isTrue(chips[1].classList.contains('pendingAdd'));
+ assert.isFalse(chips[0].classList.contains('newlyAdded'));
+ assert.isTrue(chips[1].classList.contains('newlyAdded'));
// Invalid remove is ignored.
element.dispatchEvent(
@@ -175,16 +179,16 @@
await element.updateComplete;
chips = getChips();
assert.equal(chips.length, 1);
- assert.isFalse(chips[0].classList.contains('pendingAdd'));
+ assert.isFalse(chips[0].classList.contains('newlyAdded'));
- // New groups are added to end with pendingAdd and group classes.
+ // New groups are added to end with newlyAdded and group classes.
const newGroup = makeGroup();
handleAdd({group: newGroup, confirm: false, count: 1});
await element.updateComplete;
chips = getChips();
assert.equal(chips.length, 2);
assert.isTrue(chips[1].classList.contains('group'));
- assert.isTrue(chips[1].classList.contains('pendingAdd'));
+ assert.isTrue(chips[1].classList.contains('newlyAdded'));
// Removed groups are taken out of the list.
element.dispatchEvent(
@@ -197,7 +201,7 @@
await element.updateComplete;
chips = getChips();
assert.equal(chips.length, 1);
- assert.isFalse(chips[0].classList.contains('pendingAdd'));
+ assert.isFalse(chips[0].classList.contains('newlyAdded'));
});
test('getSuggestions uses filter correctly', () => {
@@ -260,20 +264,8 @@
});
});
- test('computeChipClass', () => {
- const account = makeAccount() as AccountInfoInput;
- assert.equal(element.computeChipClass(account), '');
- account._pendingAdd = true;
- assert.equal(element.computeChipClass(account), 'pendingAdd');
- account._group = true;
- assert.equal(element.computeChipClass(account), 'group pendingAdd');
- account._pendingAdd = false;
- assert.equal(element.computeChipClass(account), 'group');
- });
-
test('computeRemovable', async () => {
const newAccount = makeAccount() as AccountInfoInput;
- newAccount._pendingAdd = true;
element.readonly = false;
element.removableValues = [];
element.updateComplete;
@@ -320,7 +312,7 @@
assert.isTrue(element.submitEntryText());
assert.isTrue(clearStub.called);
assert.equal(
- element.additions()[0].account?.email,
+ (element.additions()[0] as AccountInfo)?.email,
'test@test' as EmailAddress
);
});
@@ -335,18 +327,11 @@
assert.deepEqual(element.additions(), [
{
- account: {
- _account_id: newAccount._account_id,
- _pendingAdd: true,
- },
+ _account_id: newAccount._account_id,
},
{
- group: {
- id: newGroup.id,
- _group: true,
- _pendingAdd: true,
- name: 'abcd' as GroupName,
- },
+ id: newGroup.id,
+ name: 'abcd' as GroupName,
},
]);
});
@@ -356,7 +341,7 @@
assert.deepEqual(element.additions(), []);
const group = makeGroup();
- const reviewer = {
+ const reviewer: RawAccountInput = {
group,
count: 10,
confirm: true,
@@ -370,13 +355,9 @@
assert.isNull(element.pendingConfirmation);
assert.deepEqual(element.additions(), [
{
- group: {
- id: group.id,
- _group: true,
- _pendingAdd: true,
- confirmed: true,
- name: 'abcd' as GroupName,
- },
+ id: group.id,
+ name: 'abcd' as GroupName,
+ confirmed: true,
},
]);
});
diff --git a/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status.ts b/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status.ts
index 954a606..cbc1428 100644
--- a/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status.ts
+++ b/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status.ts
@@ -121,12 +121,9 @@
padding: 0;
}
:host(:not([flat])) .chip,
- .icon {
+ :host(:not([flat])) .chip gr-icon {
color: var(--status-text-color);
}
- gr-icon {
- font-size: 18px;
- }
`,
];
}
@@ -158,7 +155,7 @@
<div class="chip" aria-label="Label: ${this.status}">
${this.computeStatusString()}
${this.showResolveIcon()
- ? html`<gr-icon icon="edit" filled></gr-icon>`
+ ? html`<gr-icon icon="edit" filled small></gr-icon>`
: ''}
</div>
</a>
diff --git a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts
index 2d6c2b5..456cdff 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts
+++ b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts
@@ -17,7 +17,7 @@
import '../gr-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog';
import '../gr-account-label/gr-account-label';
import {getAppContext} from '../../../services/app-context';
-import {css, html, LitElement, PropertyValues} from 'lit';
+import {css, html, LitElement, nothing, PropertyValues} from 'lit';
import {customElement, property, query, state} from 'lit/decorators';
import {resolve} from '../../../models/dependency';
import {GerritNav} from '../../core/gr-navigation/gr-navigation';
@@ -34,15 +34,18 @@
import {
Comment,
DraftInfo,
+ getContentInCommentRange,
+ hasUserSuggestion,
isDraftOrUnsaved,
isRobot,
isUnsaved,
+ USER_SUGGESTION_START_PATTERN,
} from '../../../utils/comment-util';
import {
OpenFixPreviewEventDetail,
ValueChangedEvent,
} from '../../../types/events';
-import {fire, fireEvent} from '../../../utils/event-util';
+import {fire, fireAlert, fireEvent} from '../../../utils/event-util';
import {assertIsDefined} from '../../../utils/common-util';
import {Key, Modifier} from '../../../utils/dom-util';
import {commentsModelToken} from '../../../models/comments/comments-model';
@@ -51,13 +54,15 @@
import {ShortcutController} from '../../lit/shortcut-controller';
import {classMap} from 'lit/directives/class-map';
import {LineNumber} from '../../../api/diff';
-import {CommentSide} from '../../../constants/constants';
+import {CommentSide, SpecialFilePath} from '../../../constants/constants';
import {getRandomInt} from '../../../utils/math-util';
import {Subject} from 'rxjs';
import {debounceTime} from 'rxjs/operators';
import {configModelToken} from '../../../models/config/config-model';
import {changeModelToken} from '../../../models/change/change-model';
import {Interaction} from '../../../constants/reporting';
+import {KnownExperimentId} from '../../../services/flags/flags';
+import {isBase64FileContent} from '../../../api/rest-api';
const UNSAVED_MESSAGE = 'Unable to save draft';
@@ -199,6 +204,8 @@
private readonly reporting = getAppContext().reportingService;
+ private readonly flagsService = getAppContext().flagsService;
+
private readonly getChangeModel = resolve(this, changeModelToken);
// Private but used in tests.
@@ -476,7 +483,7 @@
<div class="body">
${this.renderRobotAuthor()} ${this.renderEditingTextarea()}
${this.renderCommentMessage()} ${this.renderHumanActions()}
- ${this.renderRobotActions()}
+ ${this.renderRobotActions()} ${this.renderSuggestEditActions()}
</div>
</div>
${this.renderConfirmDialog()}
@@ -702,13 +709,49 @@
return html`
<div class="rightActions">
${this.autoSaving ? html`. ` : ''}
- ${this.renderDiscardButton()} ${this.renderEditButton()}
+ ${this.renderDiscardButton()} ${this.renderSuggestEditButton()}
+ ${this.renderPreviewSuggestEditButton()} ${this.renderEditButton()}
${this.renderCancelButton()} ${this.renderSaveButton()}
${this.renderCopyLinkIcon()}
</div>
`;
}
+ private renderPreviewSuggestEditButton() {
+ if (!this.flagsService.isEnabled(KnownExperimentId.SUGGEST_EDIT)) {
+ return nothing;
+ }
+ assertIsDefined(this.comment, 'comment');
+ if (!hasUserSuggestion(this.comment)) return nothing;
+ return html`
+ <gr-button
+ link
+ secondary
+ class="action show-fix"
+ ?disabled=${this.saving}
+ @click=${this.handleShowFix}
+ >
+ Preview Fix
+ </gr-button>
+ `;
+ }
+
+ private renderSuggestEditButton() {
+ if (!this.flagsService.isEnabled(KnownExperimentId.SUGGEST_EDIT)) {
+ return nothing;
+ }
+ assertIsDefined(this.comment, 'comment');
+ if (hasUserSuggestion(this.comment)) return nothing;
+ // TODO(milutin): remove this check once suggesting on commit message is
+ // fixed. Currently diff line doesn't match commit message line, because
+ // of metadata in diff, which aren't in content api request.
+ if (this.comment.path === SpecialFilePath.COMMIT_MESSAGE) return nothing;
+ // TODO(milutin): do not show for author/owner
+ return html`<gr-button link class="action" @click=${this.createSuggestEdit}
+ >Suggest Fix</gr-button
+ >`;
+ }
+
private renderDiscardButton() {
if (this.editing) return;
return html`<gr-button
@@ -773,6 +816,22 @@
`;
}
+ private renderSuggestEditActions() {
+ if (!this.flagsService.isEnabled(KnownExperimentId.SUGGEST_EDIT)) {
+ return nothing;
+ }
+ if (
+ !this.account ||
+ isRobot(this.comment) ||
+ isDraftOrUnsaved(this.comment)
+ ) {
+ return nothing;
+ }
+ return html`
+ <div class="robotActions">${this.renderPreviewSuggestEditButton()}</div>
+ `;
+ }
+
private renderShowFixButton() {
if (!(this.comment as RobotCommentInfo)?.fix_suggestions) return;
return html`
@@ -947,6 +1006,27 @@
fire(this, 'open-fix-preview', this.getEventPayload());
}
+ async createSuggestEdit() {
+ assertIsDefined(this.comment, 'comment');
+ assertIsDefined(this.changeNum, 'changeNum');
+ // TODO(milutin): showing a toast while the file is being loaded.
+ const file = await this.restApiService.getFileContent(
+ this.changeNum,
+ this.comment.path!,
+ this.comment.patch_set!
+ );
+ if (!file || !isBase64FileContent(file) || !file.content) {
+ fireAlert(this, 'Cannot create suggestion for this file');
+ return;
+ }
+ const line = getContentInCommentRange(file.content, this.comment);
+ if (!line) {
+ fireAlert(this, 'Cannot create suggestion for comment selection');
+ return;
+ }
+ this.messageText += `${USER_SUGGESTION_START_PATTERN}${line}${'\n```'}`;
+ }
+
// private, but visible for testing
cancel() {
assertIsDefined(this.comment, 'comment');
diff --git a/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard.ts b/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard.ts
index 2b62b34..cdd4c4c 100644
--- a/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard.ts
+++ b/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard.ts
@@ -10,8 +10,9 @@
import {classMap} from 'lit/directives/class-map';
import {ifDefined} from 'lit/directives/if-defined';
import {LitElement, css, html} from 'lit';
-import {customElement, property} from 'lit/decorators';
+import {customElement, property, query} from 'lit/decorators';
import {GrButton} from '../gr-button/gr-button';
+import {GrIcon} from '../gr-icon/gr-icon';
const COPY_TIMEOUT_MS = 1000;
@@ -34,6 +35,9 @@
@property({type: Boolean})
hideInput = false;
+ @query('#icon')
+ iconEl!: GrIcon;
+
static override get styles() {
return [
css`
@@ -55,17 +59,6 @@
line-height: var(--line-height-mono);
width: 100%;
}
- /*
- * Typically icons are 20px, which is the normal line-height.
- * The copy icon is too prominent at 20px, so we choose 16px
- * here, but add 2x2px padding below, so the entire
- * component should still fit nicely into a normal inline
- * layout flow.
- */
- #icon {
- height: 16px;
- width: 16px;
- }
gr-icon {
color: var(--deemphasized-text-color);
}
@@ -107,7 +100,7 @@
@click=${this._copyToClipboard}
aria-label="Click to copy to clipboard"
>
- <gr-icon id="icon" icon="content_copy"></gr-icon>
+ <gr-icon id="icon" icon="content_copy" small></gr-icon>
</gr-button>
</gr-tooltip-content>
</div>
@@ -130,12 +123,8 @@
this.text = queryAndAssert<HTMLInputElement>(this, '#input').value;
assertIsDefined(this.text, 'text');
- this.iconEl.innerText = 'check';
+ this.iconEl.icon = 'check';
navigator.clipboard.writeText(this.text);
- setTimeout(() => (this.iconEl.innerText = 'content_copy'), COPY_TIMEOUT_MS);
- }
-
- private get iconEl(): HTMLSpanElement {
- return queryAndAssert<HTMLSpanElement>(this, '#icon');
+ setTimeout(() => (this.iconEl.icon = 'content_copy'), COPY_TIMEOUT_MS);
}
}
diff --git a/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard_test.ts b/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard_test.ts
index 7b441d3..b10f488 100644
--- a/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard_test.ts
@@ -43,7 +43,7 @@
role="button"
tabindex="0"
>
- <gr-icon icon="content_copy" id="icon"></gr-icon>
+ <gr-icon icon="content_copy" id="icon" small></gr-icon>
</gr-button>
</gr-tooltip-content>
</div>
diff --git a/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content.ts b/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content.ts
index ac910ea..5e18afe 100644
--- a/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content.ts
+++ b/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content.ts
@@ -25,6 +25,7 @@
import {nothing} from 'lit';
import {classMap} from 'lit/directives/class-map';
import {when} from 'lit/directives/when';
+import {fontStyles} from '../../../styles/gr-font-styles';
const RESTORED_MESSAGE = 'Content restored from a previous edit.';
const STORAGE_DEBOUNCE_INTERVAL_MS = 400;
@@ -110,6 +111,7 @@
static override get styles() {
return [
sharedStyles,
+ fontStyles,
css`
:host {
display: block;
@@ -166,7 +168,6 @@
}
.show-all-container gr-icon {
color: inherit;
- font-size: 18px;
}
.cancel-button {
margin-right: var(--spacing-l);
@@ -175,8 +176,6 @@
margin-right: var(--spacing-xs);
}
gr-button {
- font-family: var(--font-family);
- line-height: var(--line-height-normal);
padding: var(--spacing-xs);
}
`,
@@ -233,7 +232,7 @@
return nothing;
return html`
- <div class="show-all-container">
+ <div class="show-all-container font-normal">
${when(
this.commitCollapsible && !this.editing,
() => html`
@@ -242,15 +241,17 @@
class="show-all-button"
@click=${this.toggleCommitCollapsed}
>
- ${when(
- !this.commitCollapsed,
- () => html`<gr-icon icon="expand_less"></gr-icon>`
- )}
- ${when(
- this.commitCollapsed,
- () => html`<gr-icon icon="expand_more"></gr-icon>`
- )}
- ${this.commitCollapsed ? 'Show all' : 'Show less'}
+ <div>
+ ${when(
+ !this.commitCollapsed,
+ () => html`<gr-icon icon="expand_less" small></gr-icon>`
+ )}
+ ${when(
+ this.commitCollapsed,
+ () => html`<gr-icon icon="expand_more" small></gr-icon>`
+ )}
+ <span>${this.commitCollapsed ? 'Show all' : 'Show less'}</span>
+ </div>
</gr-button>
<div class="flex-space"></div>
`
@@ -263,7 +264,10 @@
class="edit-commit-message"
title="Edit commit message"
@click=${this.handleEditCommitMessage}
- ><gr-icon icon="edit" filled></gr-icon> Edit</gr-button
+ ><div>
+ <gr-icon icon="edit" filled small></gr-icon>
+ <span>Edit</span>
+ </div></gr-button
>
`
)}
diff --git a/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content_test.ts b/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content_test.ts
index ed09c49..d2663ed 100644
--- a/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content_test.ts
@@ -27,7 +27,7 @@
<div class="collapsed viewer">
<slot> </slot>
</div>
- <div class="show-all-container">
+ <div class="show-all-container font-normal">
<gr-button
aria-disabled="false"
class="show-all-button"
@@ -35,8 +35,10 @@
role="button"
tabindex="0"
>
- <gr-icon icon="expand_more"></gr-icon>
- Show all
+ <div>
+ <gr-icon icon="expand_more" small></gr-icon>
+ <span>Show all</span>
+ </div>
</gr-button>
<div class="flex-space"></div>
<gr-button
@@ -47,8 +49,10 @@
tabindex="0"
title="Edit commit message"
>
- <gr-icon icon="edit" filled></gr-icon>
- Edit
+ <div>
+ <gr-icon icon="edit" filled small></gr-icon>
+ <span>Edit</span>
+ </div>
</gr-button>
</div>
<gr-endpoint-slot name="above-actions"> </gr-endpoint-slot>
diff --git a/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label.ts b/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label.ts
index 231b6fd..19cda0f 100644
--- a/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label.ts
+++ b/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label.ts
@@ -144,10 +144,10 @@
}
gr-button gr-icon {
color: inherit;
- font-size: 18px;
}
gr-button.pencil {
- --gr-button-padding: 0px 0px;
+ --gr-button-padding: var(--spacing-s);
+ --margin: calc(0px - var(--spacing-s));
}
`,
];
@@ -188,8 +188,11 @@
class="pencil ${this.computeLabelClass()}"
@click=${this.showDropdown}
title=${this.computeLabel()}
- ><gr-icon icon="edit" filled></gr-icon
- ></gr-button>`;
+ >
+ <div>
+ <gr-icon icon="edit" filled small></gr-icon>
+ </div>
+ </gr-button>`;
} else {
return html`<label
class=${this.computeLabelClass()}
diff --git a/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account.ts b/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account.ts
index d3b16c8..963c65f 100644
--- a/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account.ts
+++ b/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account.ts
@@ -128,9 +128,7 @@
top: 2px;
}
gr-icon.attentionIcon {
- font-size: 14px;
- position: relative;
- top: 3px;
+ transform: scaleX(0.8);
}
gr-icon.linkIcon {
font-size: var(--line-height-normal, 20px);
@@ -270,6 +268,7 @@
<gr-icon
icon="label_important"
filled
+ small
class="attentionIcon"
></gr-icon>
<span> ${this.computePronoun()} turn to take action. </span>
diff --git a/polygerrit-ui/app/elements/shared/gr-icon/gr-icon.ts b/polygerrit-ui/app/elements/shared/gr-icon/gr-icon.ts
index 00f8ae9..cc6231a 100644
--- a/polygerrit-ui/app/elements/shared/gr-icon/gr-icon.ts
+++ b/polygerrit-ui/app/elements/shared/gr-icon/gr-icon.ts
@@ -21,7 +21,8 @@
* `icon`.
*
* @attr {String} icon - the icon to display
- * @attr {Boolean} filled - whether the icon should be filled.
+ * @attr {Boolean} filled - whether the icon should be filled
+ * @attr {Boolean} small - whether the icon should be smaller than usual
*/
@customElement('gr-icon')
export class GrIcon extends LitElement {
@@ -51,6 +52,11 @@
font-variation-settings: 'FILL' 0;
vertical-align: top;
}
+ :host([small]) {
+ font-size: 16px;
+ position: relative;
+ top: 2px;
+ }
:host([filled]) {
font-variation-settings: 'FILL' 1;
}
diff --git a/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.ts b/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.ts
index 583a5d1..23d0673 100644
--- a/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.ts
+++ b/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.ts
@@ -119,8 +119,6 @@
private readonly restApiService = getAppContext().restApiService;
- private disableEnterKeyForSelectingSuggestion = false;
-
private changeNum?: NumericChangeId;
/** Called in disconnectedCallback. */
@@ -297,7 +295,7 @@
// private but used in test
closeDropdown() {
- if (this.isMentionsDropdownActive()) {
+ if (this.isMentionsDropdownActive(this.text)) {
this.mentionsSuggestions?.close();
} else {
this.emojiSuggestions?.close();
@@ -350,7 +348,6 @@
e.stopPropagation();
this.getVisibleDropdown().cursorUp();
this.textarea!.textarea.focus();
- this.disableEnterKeyForSelectingSuggestion = false;
}
private handleDownKey(e: KeyboardEvent) {
@@ -361,7 +358,6 @@
e.stopPropagation();
this.getVisibleDropdown().cursorDown();
this.textarea!.textarea.focus();
- this.disableEnterKeyForSelectingSuggestion = false;
}
private handleTabKey(e: KeyboardEvent) {
@@ -369,7 +365,7 @@
// has only typed ':'.
if (
!this.isDropdownVisible() ||
- this.disableEnterKeyForSelectingSuggestion
+ (this.isEmojiDropdownActive() && this.currentSearchString === '')
) {
return;
}
@@ -384,7 +380,7 @@
// has only typed ':'. Also make sure that shortcuts aren't clobbered.
if (
!this.isDropdownVisible() ||
- this.disableEnterKeyForSelectingSuggestion
+ (this.isEmojiDropdownActive() && this.currentSearchString === '')
) {
this.indent(e);
return;
@@ -512,9 +508,9 @@
}
}
- private isMentionsDropdownActive() {
+ private isMentionsDropdownActive(text: string) {
return (
- this.specialCharIndex !== null && this.text[this.specialCharIndex] === '@'
+ this.specialCharIndex !== null && text[this.specialCharIndex] === '@'
);
}
@@ -548,10 +544,19 @@
const text = e.detail.value ?? '';
- if (!this.isMentionsDropdownActive()) {
- if (charAtCursor === ':' && this.specialCharIndex === null) {
+ if (this.flagsService.isEnabled(KnownExperimentId.MENTION_USERS)) {
+ // specialCharIndex needs to be assigned before isMentionsDropdownActive
+ // is called
+ if (charAtCursor === '@' && this.specialCharIndex === null) {
this.specialCharIndex = this.getSpecialCharIndex(text);
}
+ }
+ if (charAtCursor === ':' && this.specialCharIndex === null) {
+ this.specialCharIndex = this.getSpecialCharIndex(text);
+ }
+
+ // this.text does not contain newly typed character yet
+ if (!this.isMentionsDropdownActive(text)) {
if (this.specialCharIndex !== null) {
this.openOrResetDropdown(
this.emojiSuggestions!,
@@ -566,9 +571,6 @@
if (!this.flagsService.isEnabled(KnownExperimentId.MENTION_USERS)) return;
- if (charAtCursor === '@' && this.specialCharIndex === null) {
- this.specialCharIndex = this.getSpecialCharIndex(text);
- }
if (this.specialCharIndex !== null) {
this.openOrResetDropdown(
this.mentionsSuggestions!,
@@ -606,13 +608,11 @@
determineEmojiSuggestions(suggestionsText: string) {
if (!suggestionsText.length) {
this.formatSuggestions(ALL_SUGGESTIONS);
- this.disableEnterKeyForSelectingSuggestion = true;
} else {
const matches = ALL_SUGGESTIONS.filter(suggestion =>
suggestion.match.includes(suggestionsText)
).slice(0, MAX_ITEMS_DROPDOWN);
this.formatSuggestions(matches);
- this.disableEnterKeyForSelectingSuggestion = false;
}
}
diff --git a/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea_test.ts b/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea_test.ts
index 0b3bb01..d8b4ad3 100644
--- a/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea_test.ts
@@ -161,6 +161,7 @@
test('emoji dropdown does not open if mention dropdown is open', async () => {
const listenerStub = sinon.stub();
element.addEventListener('bind-value-changed', listenerStub);
+ const resetSpy = sinon.spy(element, 'resetDropdown');
stubRestApi('getSuggestedAccounts').returns(
Promise.resolve([
createAccountWithEmail('abc@google.com'),
@@ -182,6 +183,8 @@
await waitUntil(() => element.mentions.length > 0);
await element.updateComplete;
+ assert.isFalse(resetSpy.called);
+
assert.isTrue(element.emojiSuggestions!.isHidden);
assert.isFalse(element.mentionsSuggestions!.isHidden);
diff --git a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-group.ts b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-group.ts
index eac76da..5a34ae3 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-group.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-group.ts
@@ -6,7 +6,7 @@
import {BLANK_LINE, GrDiffLine, GrDiffLineType} from './gr-diff-line';
import {LineRange, Side} from '../../../api/diff';
import {LineNumber} from './gr-diff-line';
-import {assertIsDefined, check} from '../../../utils/common-util';
+import {assertIsDefined, assert} from '../../../utils/common-util';
import {untilRendered} from '../../../utils/dom-util';
export enum GrDiffGroupType {
@@ -267,7 +267,7 @@
};
} else {
assertIsDefined(options.lines);
- check(options.lines.length > 0, 'diff group must have lines');
+ assert(options.lines.length > 0, 'diff group must have lines');
for (const line of options.lines) {
this.addLine(line);
}
diff --git a/polygerrit-ui/app/services/flags/flags.ts b/polygerrit-ui/app/services/flags/flags.ts
index 54f1a01..c1a63a7 100644
--- a/polygerrit-ui/app/services/flags/flags.ts
+++ b/polygerrit-ui/app/services/flags/flags.ts
@@ -19,6 +19,7 @@
BULK_ACTIONS = 'UiFeature__bulk_actions_dashboard',
DIFF_RENDERING_LIT = 'UiFeature__diff_rendering_lit',
PUSH_NOTIFICATIONS = 'UiFeature__push_notifications',
+ SUGGEST_EDIT = 'UiFeature__suggest_edit',
RELATED_CHANGES_SUBMITTABILITY = 'UiFeature__related_changes_submittability',
MENTION_USERS = 'UiFeature__mention_users',
}
diff --git a/polygerrit-ui/app/services/gr-rest-api/gr-rest-api-impl.ts b/polygerrit-ui/app/services/gr-rest-api/gr-rest-api-impl.ts
index 20493a3..16aa13a 100644
--- a/polygerrit-ui/app/services/gr-rest-api/gr-rest-api-impl.ts
+++ b/polygerrit-ui/app/services/gr-rest-api/gr-rest-api-impl.ts
@@ -119,6 +119,7 @@
TagInput,
TopMenuEntryInfo,
UrlEncodedCommentId,
+ FixReplacementInfo,
} from '../../types/common';
import {
DiffInfo,
@@ -2092,6 +2093,23 @@
});
}
+ getFixPreview(
+ changeNum: NumericChangeId,
+ patchNum: PatchSetNum,
+ fixReplacementInfos: FixReplacementInfo[]
+ ): Promise<FilePathToDiffInfoMap | undefined> {
+ return this._getChangeURLAndSend({
+ method: HttpMethod.POST,
+ changeNum,
+ patchNum,
+ endpoint: '/fix:preview',
+ reportEndpointAsId: true,
+ headers: {Accept: 'application/json'},
+ parseResponse: true,
+ body: {fix_replacement_infos: fixReplacementInfos},
+ }) as Promise<FilePathToDiffInfoMap | undefined>;
+ }
+
getRobotCommentFixPreview(
changeNum: NumericChangeId,
patchNum: PatchSetNum,
@@ -2108,6 +2126,22 @@
applyFixSuggestion(
changeNum: NumericChangeId,
patchNum: PatchSetNum,
+ fixReplacementInfos: FixReplacementInfo[]
+ ): Promise<Response> {
+ return this._getChangeURLAndSend({
+ method: HttpMethod.POST,
+ changeNum,
+ patchNum,
+ endpoint: '/fix:apply',
+ reportEndpointAsId: true,
+ headers: {Accept: 'application/json'},
+ body: {fix_replacement_infos: fixReplacementInfos},
+ });
+ }
+
+ applyRobotFixSuggestion(
+ changeNum: NumericChangeId,
+ patchNum: PatchSetNum,
fixId: string
): Promise<Response> {
return this._getChangeURLAndSend({
diff --git a/polygerrit-ui/app/services/gr-rest-api/gr-rest-api.ts b/polygerrit-ui/app/services/gr-rest-api/gr-rest-api.ts
index 14b3ae5..ad574b6 100644
--- a/polygerrit-ui/app/services/gr-rest-api/gr-rest-api.ts
+++ b/polygerrit-ui/app/services/gr-rest-api/gr-rest-api.ts
@@ -41,6 +41,7 @@
FileNameToFileInfoMap,
FilePathToDiffInfoMap,
FixId,
+ FixReplacementInfo,
GitRef,
GpgKeyId,
GpgKeyInfo,
@@ -662,15 +663,45 @@
*/
awaitPendingDiffDrafts(): Promise<void>;
+ /**
+ * Preview Stored Fix
+ * Gets the diffs of all files for a certain {fix-id} associated with apply fix.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#preview-stored-fix
+ */
getRobotCommentFixPreview(
changeNum: NumericChangeId,
patchNum: PatchSetNum,
fixId: FixId
): Promise<FilePathToDiffInfoMap | undefined>;
+ /**
+ * Preview Provided fix
+ * Gets the diffs of all files for a provided fix replacements infos
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#preview-provided-fix
+ */
+ getFixPreview(
+ changeNum: NumericChangeId,
+ patchNum: PatchSetNum,
+ fixReplacementInfos: FixReplacementInfo[]
+ ): Promise<FilePathToDiffInfoMap | undefined>;
+
+ /**
+ * Apply Provided Fix
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#apply-provided-fix
+ */
applyFixSuggestion(
changeNum: NumericChangeId,
patchNum: PatchSetNum,
+ fixReplacementInfos: FixReplacementInfo[]
+ ): Promise<Response>;
+
+ /**
+ * Apply Stored Fix
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#apply-stored-fix
+ */
+ applyRobotFixSuggestion(
+ changeNum: NumericChangeId,
+ patchNum: PatchSetNum,
fixId: string
): Promise<Response>;
diff --git a/polygerrit-ui/app/services/service-worker-installer.ts b/polygerrit-ui/app/services/service-worker-installer.ts
index 6f889e8..3af79a3 100644
--- a/polygerrit-ui/app/services/service-worker-installer.ts
+++ b/polygerrit-ui/app/services/service-worker-installer.ts
@@ -6,6 +6,8 @@
import {FlagsService, KnownExperimentId} from './flags/flags';
import {registerServiceWorker} from '../utils/worker-util';
+import {UserModel} from '../models/user/user-model';
+import {AccountDetailInfo} from '../api/rest-api';
/** Type of incoming messages for ServiceWorker. */
export enum ServiceWorkerMessageType {
@@ -17,7 +19,14 @@
export class ServiceWorkerInstaller {
initialized = false;
- constructor(private readonly flagsService: FlagsService) {}
+ account?: AccountDetailInfo;
+
+ constructor(
+ private readonly flagsService: FlagsService,
+ private readonly userModel: UserModel
+ ) {
+ this.userModel.account$.subscribe(acc => (this.account = acc));
+ }
async init() {
if (this.initialized) return;
@@ -44,6 +53,7 @@
this.startTriggerTimer();
navigator.serviceWorker.controller?.postMessage({
type: ServiceWorkerMessageType.TRIGGER_NOTIFICATIONS,
+ account: this.account,
});
}, TRIGGER_NOTIFICATION_UPDATES_MS);
}
diff --git a/polygerrit-ui/app/services/service-worker-installer_test.ts b/polygerrit-ui/app/services/service-worker-installer_test.ts
index 9de8690..d6b0c02 100644
--- a/polygerrit-ui/app/services/service-worker-installer_test.ts
+++ b/polygerrit-ui/app/services/service-worker-installer_test.ts
@@ -12,8 +12,12 @@
setup(() => {
const flagsService = getAppContext().flagsService;
+ const userModel = getAppContext().userModel;
sinon.stub(flagsService, 'isEnabled').returns(true);
- serviceWorkerInstaller = new ServiceWorkerInstaller(flagsService);
+ serviceWorkerInstaller = new ServiceWorkerInstaller(
+ flagsService,
+ userModel
+ );
});
test('init', () => {
diff --git a/polygerrit-ui/app/test/mocks/gr-rest-api_mock.ts b/polygerrit-ui/app/test/mocks/gr-rest-api_mock.ts
index 3c4bb62..492bd8d 100644
--- a/polygerrit-ui/app/test/mocks/gr-rest-api_mock.ts
+++ b/polygerrit-ui/app/test/mocks/gr-rest-api_mock.ts
@@ -93,6 +93,9 @@
applyFixSuggestion(): Promise<Response> {
return Promise.resolve(new Response());
},
+ applyRobotFixSuggestion(): Promise<Response> {
+ return Promise.resolve(new Response());
+ },
awaitPendingDiffDrafts(): Promise<void> {
return Promise.resolve();
},
@@ -397,6 +400,9 @@
getReviewedFiles(): Promise<string[] | undefined> {
return Promise.resolve([]);
},
+ getFixPreview(): Promise<FilePathToDiffInfoMap | undefined> {
+ return Promise.resolve({});
+ },
getRobotCommentFixPreview(): Promise<FilePathToDiffInfoMap | undefined> {
return Promise.resolve({});
},
diff --git a/polygerrit-ui/app/test/test-data-generators.ts b/polygerrit-ui/app/test/test-data-generators.ts
index e7e85c2..81ca830 100644
--- a/polygerrit-ui/app/test/test-data-generators.ts
+++ b/polygerrit-ui/app/test/test-data-generators.ts
@@ -72,6 +72,7 @@
} from '../types/common';
import {
AccountsVisibility,
+ AccountTag,
AppTheme,
AuthType,
ChangeStatus,
@@ -188,6 +189,13 @@
};
}
+export function createServiceUserWithId(id = 5): AccountInfo {
+ return {
+ ...createAccountWithId(id),
+ tags: [AccountTag.SERVICE_USER],
+ };
+}
+
export function createAccountDetailWithId(id = 5): AccountDetailInfo {
return {
_account_id: id as AccountId,
diff --git a/polygerrit-ui/app/tsconfig.json b/polygerrit-ui/app/tsconfig.json
index b799e58..8101b22 100644
--- a/polygerrit-ui/app/tsconfig.json
+++ b/polygerrit-ui/app/tsconfig.json
@@ -46,7 +46,7 @@
"lib": [
"dom",
"dom.iterable",
- "es2019",
+ "es2020",
"webworker"
],
diff --git a/polygerrit-ui/app/types/common.ts b/polygerrit-ui/app/types/common.ts
index 77b0305..0e05828 100644
--- a/polygerrit-ui/app/types/common.ts
+++ b/polygerrit-ui/app/types/common.ts
@@ -115,6 +115,7 @@
WebLinkInfo,
isDetailedLabelInfo,
isQuickLabelInfo,
+ Base64FileContent,
} from '../api/rest-api';
import {DiffInfo, IgnoreWhitespaceType} from './diff';
@@ -128,6 +129,7 @@
ApprovalInfo,
AuthInfo,
AvatarInfo,
+ Base64FileContent,
BasePatchSetNum,
BranchName,
BrandType,
@@ -923,16 +925,6 @@
}
/**
- * Represent a file in a base64 encoding; GrRestApiInterface returns it from some
- * methods
- */
-export interface Base64FileContent {
- content: string | null;
- type: string | null;
- ok: true;
-}
-
-/**
* The WatchedProjectsInfo entity contains information about a project watch for a user
* https://gerrit-review.googlesource.com/Documentation/rest-api-accounts.html#project-watch-info
*/
diff --git a/polygerrit-ui/app/utils/account-util.ts b/polygerrit-ui/app/utils/account-util.ts
index de2c20b..012dc1c 100644
--- a/polygerrit-ui/app/utils/account-util.ts
+++ b/polygerrit-ui/app/utils/account-util.ts
@@ -19,13 +19,13 @@
} from '../types/common';
import {AccountTag, ReviewerState} from '../constants/constants';
import {assertNever, hasOwnProperty} from './common-util';
-import {AccountAddition} from '../elements/shared/gr-account-list/gr-account-list';
import {getDisplayName} from './display-name-util';
import {getApprovalInfo} from './label-util';
import {RestApiService} from '../services/gr-rest-api/gr-rest-api';
export const ACCOUNT_TEMPLATE_REGEX = '<GERRIT_ACCOUNT_(\\d+)>';
const SUGGESTIONS_LIMIT = 15;
+// https://html.spec.whatwg.org/multipage/input.html#valid-e-mail-address
export const MENTIONS_REGEX =
/(?:^|\s)@([a-zA-Z0-9.!#$%&'*+=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*)(?:\s+|$)/g;
@@ -35,30 +35,6 @@
throw new Error('Account has neither _account_id nor email.');
}
-export function mapReviewer(addition: AccountAddition): ReviewerInput {
- if (addition.account) {
- return {reviewer: accountKey(addition.account)};
- }
- if (addition.group) {
- const reviewer = decodeURIComponent(addition.group.id) as GroupId;
- const confirmed = addition.group.confirmed;
- return {reviewer, confirmed};
- }
- throw new Error('Reviewer must be either an account or a group.');
-}
-
-export function isReviewerOrCC(
- change: ChangeInfo,
- reviewerAddition: AccountAddition
-): boolean {
- const reviewers = [
- ...(change.reviewers[ReviewerState.CC] ?? []),
- ...(change.reviewers[ReviewerState.REVIEWER] ?? []),
- ];
- const reviewer = mapReviewer(reviewerAddition);
- return reviewers.some(r => accountOrGroupKey(r) === reviewer.reviewer);
-}
-
export function isServiceUser(account?: AccountInfo): boolean {
return !!account?.tags?.includes(AccountTag.SERVICE_USER);
}
@@ -81,6 +57,18 @@
assertNever(entry, 'entry must be account or group');
}
+export function isAccountNewlyAdded(
+ account: AccountInfo | GroupInfo,
+ state?: ReviewerState,
+ change?: ChangeInfo
+) {
+ if (!change || !state) return false;
+ const accounts = [...(change.reviewers[state] ?? [])];
+ return !accounts.some(
+ a => accountOrGroupKey(a) === accountOrGroupKey(account)
+ );
+}
+
export function uniqueDefinedAvatar(
account: AccountInfo,
index: number,
@@ -213,5 +201,26 @@
*/
export function extractMentionedEmails(text?: string): EmailAddress[] {
if (!text) return [];
- return [...text.matchAll(MENTIONS_REGEX)].map(m => m[1] as EmailAddress);
+ let match;
+ const emails = [];
+ while ((match = MENTIONS_REGEX.exec(text))) {
+ emails.push(match[1] as EmailAddress);
+ }
+ return emails;
+}
+
+export function toReviewInput(
+ account: AccountInfo | GroupInfo,
+ state: ReviewerState
+): ReviewerInput {
+ if (isAccount(account)) {
+ return {
+ reviewer: accountKey(account),
+ state,
+ };
+ } else if (isGroup(account)) {
+ const reviewer = decodeURIComponent(account.id) as GroupId;
+ return {reviewer, state};
+ }
+ throw new Error('Must be either an account or a group.');
}
diff --git a/polygerrit-ui/app/utils/account-util_test.ts b/polygerrit-ui/app/utils/account-util_test.ts
index 5c58d5a..68fa386 100644
--- a/polygerrit-ui/app/utils/account-util_test.ts
+++ b/polygerrit-ui/app/utils/account-util_test.ts
@@ -81,6 +81,9 @@
text = '@abcgoogle.com';
assert.deepEqual(extractMentionedEmails(text), []);
+ text = '@abc@googlecom';
+ assert.deepEqual(extractMentionedEmails(text), ['abc@googlecom']);
+
// with newline before email
text = '\n\n\n random text \n\n@abc@google.com';
assert.deepEqual(extractMentionedEmails(text), ['abc@google.com']);
diff --git a/polygerrit-ui/app/utils/change-util.ts b/polygerrit-ui/app/utils/change-util.ts
index d6d2f7d..cf0ff53 100644
--- a/polygerrit-ui/app/utils/change-util.ts
+++ b/polygerrit-ui/app/utils/change-util.ts
@@ -14,6 +14,7 @@
} from '../types/common';
import {ParsedChangeInfo} from '../types/types';
import {ChangeStates} from '../elements/shared/gr-change-status/gr-change-status';
+import {isServiceUser} from './account-util';
// This can be wrong! See WARNING above
interface ChangeStatusesOptions {
@@ -249,6 +250,11 @@
);
}
+export function hasHumanReviewer(change?: ChangeInfo): boolean {
+ const reviewers = change?.reviewers.REVIEWER ?? [];
+ return reviewers.some(r => !isServiceUser(r));
+}
+
export function isRemovableReviewer(
change?: ChangeInfo,
reviewer?: AccountInfo
diff --git a/polygerrit-ui/app/utils/comment-util.ts b/polygerrit-ui/app/utils/comment-util.ts
index c5cbe64..c187a50 100644
--- a/polygerrit-ui/app/utils/comment-util.ts
+++ b/polygerrit-ui/app/utils/comment-util.ts
@@ -20,6 +20,8 @@
AccountDetailInfo,
ChangeMessageInfo,
VotingRangeInfo,
+ FixSuggestionInfo,
+ FixId,
} from '../types/common';
import {CommentSide, SpecialFilePath} from '../constants/constants';
import {parseDate} from './date-util';
@@ -492,3 +494,55 @@
unsaved: isUnsaved(comment),
};
}
+
+export const USER_SUGGESTION_START_PATTERN = '```suggestion\n';
+export const USER_SUGGEST_EDIT_FIX_ID = 'user_suggestion' as FixId;
+
+export function hasUserSuggestion(comment: Comment) {
+ return comment.message?.includes(USER_SUGGESTION_START_PATTERN) ?? false;
+}
+
+export function getUserSuggestion(comment: Comment) {
+ if (!comment.message) return;
+ const start =
+ comment.message.indexOf(USER_SUGGESTION_START_PATTERN) +
+ USER_SUGGESTION_START_PATTERN.length;
+ const end = comment.message.indexOf('\n```', start);
+ return comment.message.substring(start, end);
+}
+
+/**
+ * Currently it works only on 1 line.
+ * TODO(milutin): Extend for multiline comments
+ */
+export function getContentInCommentRange(
+ fileContent: string,
+ comment: Comment
+) {
+ return fileContent.split('\n')[comment.line! - 1];
+}
+
+export function createUserFixSuggestion(
+ comment: Comment,
+ line: string,
+ replacement: string
+): FixSuggestionInfo[] {
+ return [
+ {
+ fix_id: USER_SUGGEST_EDIT_FIX_ID,
+ description: 'User suggestion',
+ replacements: [
+ {
+ path: comment.path!,
+ range: {
+ start_line: comment.line!,
+ start_character: 0,
+ end_line: comment.line!,
+ end_character: line.length,
+ },
+ replacement,
+ },
+ ],
+ },
+ ];
+}
diff --git a/polygerrit-ui/app/utils/comment-util_test.ts b/polygerrit-ui/app/utils/comment-util_test.ts
index bb9c455..dd57042 100644
--- a/polygerrit-ui/app/utils/comment-util_test.ts
+++ b/polygerrit-ui/app/utils/comment-util_test.ts
@@ -9,6 +9,12 @@
getPatchRangeForCommentUrl,
createCommentThreads,
sortComments,
+ USER_SUGGESTION_START_PATTERN,
+ hasUserSuggestion,
+ getUserSuggestion,
+ getContentInCommentRange,
+ createUserFixSuggestion,
+ USER_SUGGEST_EDIT_FIX_ID,
} from './comment-util';
import {createComment, createCommentThread} from '../test/test-data-generators';
import {CommentSide} from '../constants/constants';
@@ -223,4 +229,58 @@
assert.equal(createCommentThreads(comments).length, 2);
});
});
+
+ test('hasUserSuggestion', () => {
+ const comment = {
+ ...createComment(),
+ message: `${USER_SUGGESTION_START_PATTERN}${'test'}${'\n```'}`,
+ };
+ assert.isTrue(hasUserSuggestion(comment));
+ });
+
+ test('getUserSuggestion', () => {
+ const suggestion = 'test';
+ const comment = {
+ ...createComment(),
+ message: `${USER_SUGGESTION_START_PATTERN}${suggestion}${'\n```'}`,
+ };
+ assert.equal(getUserSuggestion(comment), suggestion);
+ });
+
+ test('getContentInCommentRange', () => {
+ const comment = {
+ ...createComment(),
+ line: 1,
+ };
+ const content = 'line1\nline2\nline3';
+ assert.equal(getContentInCommentRange(content, comment), 'line1');
+ });
+
+ test('createUserFixSuggestion', () => {
+ const comment = {
+ ...createComment(),
+ line: 1,
+ path: 'abc.txt',
+ };
+ const line = 'lane1';
+ const replacement = 'line1';
+ assert.deepEqual(createUserFixSuggestion(comment, line, replacement), [
+ {
+ fix_id: USER_SUGGEST_EDIT_FIX_ID,
+ description: 'User suggestion',
+ replacements: [
+ {
+ path: 'abc.txt',
+ range: {
+ start_line: 1,
+ start_character: 0,
+ end_line: 1,
+ end_character: line.length,
+ },
+ replacement,
+ },
+ ],
+ },
+ ]);
+ });
});
diff --git a/polygerrit-ui/app/utils/common-util.ts b/polygerrit-ui/app/utils/common-util.ts
index fe86d91..0bae49a 100644
--- a/polygerrit-ui/app/utils/common-util.ts
+++ b/polygerrit-ui/app/utils/common-util.ts
@@ -38,7 +38,7 @@
/**
* Throws an error with the provided error message if the condition is false.
*/
-export function check(
+export function assert(
condition: boolean,
errorMessage: string
): asserts condition {
@@ -48,28 +48,6 @@
/**
* Throws an error if the property is not defined.
*/
-export function checkProperty(
- condition: boolean,
- propertyName: string
-): asserts condition {
- check(condition, `missing required property '${propertyName}'`);
-}
-
-/**
- * Throws an error if the property is not defined.
- */
-export function checkRequiredProperty<T>(
- property: T,
- propertyName: string
-): asserts property is NonNullable<T> {
- if (property === undefined || property === null) {
- throw new Error(`Required property '${propertyName}' not set.`);
- }
-}
-
-/**
- * Throws an error if the property is not defined.
- */
export function assertIsDefined<T>(
val: T,
variableName = 'variable'
@@ -168,3 +146,14 @@
result.filter(t => array.find(u => compareBy(t, u)))
);
}
+
+/**
+ * Returns the elements that are present in A but not present in B.
+ */
+export function difference<T>(
+ a: T[],
+ b: T[],
+ compareBy: (t: T, u: T) => boolean = (t, u) => t === u
+): T[] {
+ return a.filter(aVal => !b.some(bVal => compareBy(aVal, bVal)));
+}
diff --git a/polygerrit-ui/app/utils/common-util_test.ts b/polygerrit-ui/app/utils/common-util_test.ts
index e07401b..c9ba060 100644
--- a/polygerrit-ui/app/utils/common-util_test.ts
+++ b/polygerrit-ui/app/utils/common-util_test.ts
@@ -9,6 +9,7 @@
areSetsEqual,
containsAll,
intersection,
+ difference,
} from './common-util';
suite('common-util tests', () => {
@@ -88,4 +89,11 @@
[foo1]
);
});
+
+ test('difference', () => {
+ assert.deepEqual(difference([1, 2, 3], []), [1, 2, 3]);
+ assert.deepEqual(difference([1, 2, 3], [2, 3, 4]), [1]);
+ assert.deepEqual(difference([1, 2, 3], [1, 2, 3]), []);
+ assert.deepEqual(difference([1, 2, 3], [4, 5, 6]), [1, 2, 3]);
+ });
});
diff --git a/polygerrit-ui/app/utils/patch-set-util.ts b/polygerrit-ui/app/utils/patch-set-util.ts
index f8ba4c3..515f2e7 100644
--- a/polygerrit-ui/app/utils/patch-set-util.ts
+++ b/polygerrit-ui/app/utils/patch-set-util.ts
@@ -9,7 +9,7 @@
RevisionPatchSetNum,
} from '../types/common';
import {EditRevisionInfo, ParsedChangeInfo} from '../types/types';
-import {check} from './common-util';
+import {assert} from './common-util';
/**
* @license
@@ -261,7 +261,7 @@
if (latest === EDIT) {
latest = allPatchSets[1].num;
}
- check(isNumber(latest), 'Latest patchset cannot be EDIT or PARENT.');
+ assert(isNumber(latest), 'Latest patchset cannot be EDIT or PARENT.');
return latest;
}
diff --git a/polygerrit-ui/app/utils/service-worker-util.ts b/polygerrit-ui/app/utils/service-worker-util.ts
new file mode 100644
index 0000000..3cf0dce
--- /dev/null
+++ b/polygerrit-ui/app/utils/service-worker-util.ts
@@ -0,0 +1,29 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import {AccountDetailInfo} from '../api/rest-api';
+import {ParsedChangeInfo} from '../types/types';
+import {parseDate} from './date-util';
+
+/**
+ * Filter changes that had change in attention set after last round
+ * of notifications. Filter out changes we already notified about.
+ */
+export function filterAttentionChangesAfter(
+ changes: ParsedChangeInfo[],
+ account: AccountDetailInfo,
+ latestUpdateTimestampMs?: number
+) {
+ if (!latestUpdateTimestampMs) return changes;
+ return changes.filter(change => {
+ const attention_set = change.attention_set![account._account_id!];
+ if (!attention_set.last_update) return false;
+ const lastUpdateTimestampMs = parseDate(
+ attention_set.last_update
+ ).valueOf();
+ return latestUpdateTimestampMs < lastUpdateTimestampMs;
+ });
+}
diff --git a/polygerrit-ui/app/utils/service-worker-util_test.ts b/polygerrit-ui/app/utils/service-worker-util_test.ts
new file mode 100644
index 0000000..0caea3b
--- /dev/null
+++ b/polygerrit-ui/app/utils/service-worker-util_test.ts
@@ -0,0 +1,48 @@
+/**
+ * @license
+ * Copyright 2017 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {Timestamp} from '../api/rest-api';
+import '../test/common-test-setup-karma';
+import {
+ createAccountDetailWithId,
+ createParsedChange,
+} from '../test/test-data-generators';
+import {ParsedChangeInfo} from '../types/types';
+import {parseDate} from './date-util';
+import {filterAttentionChangesAfter} from './service-worker-util';
+
+suite('service worker util tests', () => {
+ test('filterAttentionChangesAfter', () => {
+ const account = createAccountDetailWithId();
+ const changeBefore: ParsedChangeInfo = {
+ ...createParsedChange(),
+ attention_set: {
+ [`${account._account_id}`]: {
+ account,
+ last_update: '2016-01-12 20:24:49.000000000' as Timestamp,
+ },
+ },
+ };
+ const changeAfter: ParsedChangeInfo = {
+ ...createParsedChange(),
+ attention_set: {
+ [`${account._account_id}`]: {
+ account,
+ last_update: '2016-01-12 20:24:51.000000000' as Timestamp,
+ },
+ },
+ };
+ const changes = [changeBefore, changeAfter];
+
+ const filteredChanges = filterAttentionChangesAfter(
+ changes,
+ account,
+ parseDate('2016-01-12 20:24:50.000000000' as Timestamp).valueOf()
+ );
+
+ assert.equal(filteredChanges.length, 1);
+ assert.equal(filteredChanges[0], changeAfter);
+ });
+});
diff --git a/polygerrit-ui/app/workers/service-worker-class.ts b/polygerrit-ui/app/workers/service-worker-class.ts
new file mode 100644
index 0000000..966b6b0
--- /dev/null
+++ b/polygerrit-ui/app/workers/service-worker-class.ts
@@ -0,0 +1,56 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import {ParsedChangeInfo} from '../types/types';
+import {getReason} from '../utils/attention-set-util';
+import {readResponsePayload} from '../elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper';
+import {filterAttentionChangesAfter} from '../utils/service-worker-util';
+import {AccountDetailInfo} from '../api/rest-api';
+import {TRIGGER_NOTIFICATION_UPDATES_MS} from '../services/service-worker-installer';
+
+export class ServiceWorker {
+ constructor(private ctx: ServiceWorkerGlobalScope) {}
+
+ latestUpdateTimestampMs?: number;
+
+ showNotification(change: ParsedChangeInfo, account: AccountDetailInfo) {
+ const body = getReason(undefined, account, change);
+ // TODO(milutin): Implement event.action that
+ // focus on firstWindowClient and open change there.
+ // TODO(milutin): Add gerrit host icon
+ this.ctx.registration.showNotification(change.subject, {body});
+ }
+
+ async getChangesToNotify(account: AccountDetailInfo) {
+ // We throttle polling, since there can be many clients triggerring
+ // always only one service worker.
+ if (this.latestUpdateTimestampMs) {
+ const durationFromLatestUpdateMS =
+ Date.now() - this.latestUpdateTimestampMs;
+ if (durationFromLatestUpdateMS < TRIGGER_NOTIFICATION_UPDATES_MS) {
+ return [];
+ }
+ }
+ const changes = await this.getLatestAttentionSetChanges();
+ const latestAttentionChanges = filterAttentionChangesAfter(
+ changes,
+ account,
+ this.latestUpdateTimestampMs
+ );
+ this.latestUpdateTimestampMs = Date.now();
+ return latestAttentionChanges;
+ }
+
+ async getLatestAttentionSetChanges(): Promise<ParsedChangeInfo[]> {
+ // TODO(milutin): Implement more generic query builder
+ const response = await fetch(
+ '/changes/?O=1000081&S=0&n=25&q=attention%3Aself'
+ );
+ const payload = await readResponsePayload(response);
+ const changes = payload.parsed as unknown as ParsedChangeInfo[] | undefined;
+ return changes ?? [];
+ }
+}
diff --git a/polygerrit-ui/app/workers/service-worker-class_test.ts b/polygerrit-ui/app/workers/service-worker-class_test.ts
new file mode 100644
index 0000000..ecfef5e
--- /dev/null
+++ b/polygerrit-ui/app/workers/service-worker-class_test.ts
@@ -0,0 +1,50 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {Timestamp} from '../api/rest-api';
+import '../test/common-test-setup-karma';
+import {
+ createAccountDetailWithId,
+ createParsedChange,
+} from '../test/test-data-generators';
+import {ParsedChangeInfo} from '../types/types';
+import {parseDate} from '../utils/date-util';
+import {ServiceWorker} from './service-worker-class';
+
+suite('service worker class tests', () => {
+ let serviceWorker: ServiceWorker;
+
+ setup(() => {
+ const moctCtx = {
+ registration: {
+ showNotification: () => {},
+ },
+ } as {} as ServiceWorkerGlobalScope;
+ serviceWorker = new ServiceWorker(moctCtx);
+ });
+
+ test('notify about attention in t2 when time is t3', async () => {
+ const t1 = parseDate('2016-01-12 20:20:00' as Timestamp).getTime();
+ const t2 = '2016-01-12 20:30:00' as Timestamp;
+ const t3 = parseDate('2016-01-12 20:40:00' as Timestamp).getTime();
+ serviceWorker.latestUpdateTimestampMs = t1;
+ const account = createAccountDetailWithId();
+ const change: ParsedChangeInfo = {
+ ...createParsedChange(),
+ attention_set: {
+ [`${account._account_id}`]: {
+ account,
+ last_update: t2,
+ },
+ },
+ };
+ sinon.useFakeTimers(t3);
+ sinon
+ .stub(serviceWorker, 'getLatestAttentionSetChanges')
+ .returns(Promise.resolve([change]));
+ const changes = await serviceWorker.getChangesToNotify(account);
+ assert.equal(changes[0], change);
+ });
+});
diff --git a/polygerrit-ui/app/workers/service-worker.ts b/polygerrit-ui/app/workers/service-worker.ts
index e219946..ca1f5dc 100644
--- a/polygerrit-ui/app/workers/service-worker.ts
+++ b/polygerrit-ui/app/workers/service-worker.ts
@@ -4,12 +4,9 @@
* SPDX-License-Identifier: Apache-2.0
*/
-import {readResponsePayload} from '../elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper';
-import {
- ServiceWorkerMessageType,
- TRIGGER_NOTIFICATION_UPDATES_MS,
-} from '../services/service-worker-installer';
-import {ParsedChangeInfo} from '../types/types';
+import {AccountDetailInfo} from '../api/rest-api';
+import {ServiceWorkerMessageType} from '../services/service-worker-installer';
+import {ServiceWorker} from './service-worker-class';
/**
* `self` is for a worker what `window` is for the web app. It is called
@@ -20,49 +17,20 @@
ctx.addEventListener('message', async event => {
if (event.data?.type !== ServiceWorkerMessageType.TRIGGER_NOTIFICATIONS) {
- throw new Error(`Unsupported event type: ${event.data?.type}`);
+ // Only this notification message type exists, but we do not throw error.
+ return;
}
- const changes = await serviceWorker.getLatestAttentionSetChange();
+ const account = event.data?.account as AccountDetailInfo | undefined;
+ // Message always contains account, but we do not throw error.
+ if (!account?._account_id) return;
+ const latestAttentionChanges = await serviceWorker.getChangesToNotify(
+ account
+ );
// TODO(milutin): Implement handling more than 1 change
- if (changes && changes.length > 0) {
- serviceWorker.showNotification(changes[0]);
+ if (latestAttentionChanges && latestAttentionChanges.length > 0) {
+ serviceWorker.showNotification(latestAttentionChanges[0], account);
}
});
-class ServiceWorker {
- latestUpdateTimestampMs?: number;
-
- showNotification(change: ParsedChangeInfo) {
- // TODO(milutin): Replace with getReason from attention-set-util.
- // For get Reason you will need AccountInfo.
- const body = 'Administrator replied on the change';
- // TODO(milutin): Implement event.action that
- // focus on firstWindowClient and open change there.
- // TODO(milutin): Add gerrit host icon
- ctx.registration.showNotification(change.subject, {body});
- }
-
- async getLatestAttentionSetChange(): Promise<ParsedChangeInfo[]> {
- // We throttle polling, since there can be many clients triggerring
- // always only one service worker.
- if (this.latestUpdateTimestampMs) {
- const durationFromLatestUpdateMS =
- Date.now() - this.latestUpdateTimestampMs;
- if (durationFromLatestUpdateMS < TRIGGER_NOTIFICATION_UPDATES_MS) {
- return [];
- }
- }
- this.latestUpdateTimestampMs = Date.now();
- // TODO(milutin): Implement more generic query builder
- const response = await fetch(
- '/changes/?O=1000081&S=0&n=25&q=attention%3Aself'
- );
- const payload = await readResponsePayload(response);
- const changes = payload.parsed as unknown as ParsedChangeInfo[] | undefined;
- // TODO(milutin): Filter changes you are already notified about.
- return changes ?? [];
- }
-}
-
/** Singleton instance being referenced in `onmessage` function above. */
-const serviceWorker = new ServiceWorker();
+const serviceWorker = new ServiceWorker(ctx);