Merge "Use line number from event detail to update url"
diff --git a/.bazelversion b/.bazelversion
index 47b322c..1545d96 100644
--- a/.bazelversion
+++ b/.bazelversion
@@ -1 +1 @@
-3.4.1
+3.5.0
diff --git a/Documentation/config-gerrit.txt b/Documentation/config-gerrit.txt
index d96ae46..6b89d67 100644
--- a/Documentation/config-gerrit.txt
+++ b/Documentation/config-gerrit.txt
@@ -2828,7 +2828,8 @@
+
Enable (or disable) the `'$site_path'/logs/httpd_log` request log.
If enabled, an NCSA combined log format request log file is written
-out by the internal HTTP daemon.
+out by the internal HTTP daemon. The httpd log format is documented
+link:logs.html#_httpd_log[here].
+
`log4j.appender` with the name `httpd_log` can be configured to overwrite
programmatic configuration.
@@ -4881,6 +4882,21 @@
+
By default, 30s.
+[[sshd.gracefulStopTimeout]]sshd.gracefulStopTimeout::
++
+Set a graceful stop time. If set, Gerrit ensures that all open SSH
+sessions are preserved for a maximum period of time, before forcing the
+shutdown of the SSH daemon. During this period, no new requests
+will be accepted. This option is meant to be used in setups performing
+rolling restarts.
++
+Values should use common unit suffixes to express their setting:
++
+* s, sec, second, seconds
+* m, min, minute, minutes
++
+By default, 0 seconds (immediate shutdown).
+
[[sshd.maxConnectionsPerUser]]sshd.maxConnectionsPerUser::
+
Maximum number of concurrent SSH sessions that a user account
@@ -5006,6 +5022,7 @@
+
Enable (or disable) the `'$site_path'/logs/sshd_log` request log.
If enabled, a request log file is written out by the SSH daemon.
+The sshd log format is documented link:logs.html#_sshd_log[here].
+
`log4j.appender` with the name `sshd_log` can be configured to overwrite
programmatic configuration.
diff --git a/Documentation/config-reverseproxy.txt b/Documentation/config-reverseproxy.txt
index eff777b..3a9bcc7 100644
--- a/Documentation/config-reverseproxy.txt
+++ b/Documentation/config-reverseproxy.txt
@@ -21,6 +21,13 @@
listenUrl = proxy-http://127.0.0.1:8081/r/
----
+== Reverse proxy and client IPs
+
+When behind a reverse proxy the http_log will log the IP of the reverse proxy
+as client.ip. To log the correct client IP you must provide the
+'X-Forwarded-For' header from the reverse proxy.
+See the nginx configuration example below.
+
== Apache 2 Configuration
diff --git a/Documentation/dev-bazel.txt b/Documentation/dev-bazel.txt
index 01cd494..ee2f4a1 100644
--- a/Documentation/dev-bazel.txt
+++ b/Documentation/dev-bazel.txt
@@ -90,12 +90,12 @@
```
$ cat << EOF > ~/.bazelrc
-> build --define=ABSOLUTE_JAVABASE=<path-to-java-13>
-> build --javabase=@bazel_tools//tools/jdk:absolute_javabase
-> build --host_javabase=@bazel_tools//tools/jdk:absolute_javabase
-> build --host_java_toolchain=@bazel_tools//tools/jdk:toolchain_vanilla
-> build --java_toolchain=@bazel_tools//tools/jdk:toolchain_vanilla
-> EOF
+build --define=ABSOLUTE_JAVABASE=<path-to-java-13>
+build --javabase=@bazel_tools//tools/jdk:absolute_javabase
+build --host_javabase=@bazel_tools//tools/jdk:absolute_javabase
+build --host_java_toolchain=@bazel_tools//tools/jdk:toolchain_vanilla
+build --java_toolchain=@bazel_tools//tools/jdk:toolchain_vanilla
+EOF
```
Now, invoking Bazel with just `bazel build :release` would include
diff --git a/Documentation/dev-e2e-tests.txt b/Documentation/dev-e2e-tests.txt
index 78b2c15..c5b3bfc 100644
--- a/Documentation/dev-e2e-tests.txt
+++ b/Documentation/dev-e2e-tests.txt
@@ -27,14 +27,33 @@
leveraged to run tests at the Git protocol level.
Gatling is written in Scala, but the abstraction provided by the Gatling DSL makes the scenarios
-implementation easy even without any Scala knowledge. The
-link:https://gitenterprise.me/2019/12/20/stress-your-gerrit-with-gatling/[Stress your Gerrit with Gatling,role=external,window=_blank]
-blog post has more introductory information.
+implementation easy even without any Scala knowledge. The online `End-to-end tests`
+link:https://www.gerritcodereview.com/presentations.html#list-of-presentations[presentation,role=external,window=_blank]
+links posted on the homepage have more introductory information.
+
+== IDE: IntelliJ
Examples of scenarios can be found in the `e2e-tests` directory. The files in that directory should
be formatted using the mainstream
link:https://plugins.jetbrains.com/plugin/1347-scala[Scala plugin for IntelliJ,role=external,window=_blank].
The latter is not mandatory but preferred for `sbt` and Scala IDE purposes in this project.
+So, Eclipse can also be used alongside as a development IDE; this is described below.
+
+=== Eclipse
+
+1. Install the link:http://scala-ide.org/docs/user/gettingstarted.html[Scala plugin for Eclipse,role=external,window=_blank].
+1. Run `sbt eclipse` from the `e2e-tests` root directory.
+1. Import the resulting `e2e-tests` eclipse file inside the Gerrit project, in Eclipse.
+1. You should see errors in Eclipse telling you there are missing packages.
+1. This is due to the sbt-eclipse plugin not properly linking the Gerrit Gatling e2e tests with
+ Gatling Git plugin.
+1. You then have to right-click on the root directory and choose the build path->link source option.
+1. Then you have to browse to `.sbt/1.0/staging`, find the folder where gatling-git is contained,
+ and choose that.
+1. That last step should link the gatling-git plugin to the project; e2e tests should not show
+ errors anymore.
+1. You may get errors in the gatling-git directory; these should not affect Gerrit Gatling
+ development and can be ignored.
== How to build the tests
@@ -163,10 +182,11 @@
Plugin or otherwise non-core scenarios may do so just as well. The core java package
`com.google.gerrit.scenarios` from the example above has to be replaced with the one under which
those scenario classes are. Such extending scenarios can also add extension-specific properties.
-Early examples of this can be found in the Gerrit
-`link:https://gerrit.googlesource.com/plugins/high-availability[high-availability,role=external,window=_blank]`
-and `link:https://gerrit.googlesource.com/plugins/multi-site[multi-site,role=external,window=_blank]`
-plugins test code.
+Examples of this can be found in these Gerrit plugins test code:
+
+* `link:https://gerrit.googlesource.com/plugins/high-availability[high-availability,role=external,window=_blank]`
+* `link:https://gerrit.googlesource.com/plugins/multi-site[multi-site,role=external,window=_blank]`
+* `link:https://gerrit.googlesource.com/plugins/rename-project[rename-project,role=external,window=_blank]`
Further above, the `_PROJECT` keyword is prefixed with an underscore, which means that its value
gets automatically generated by the scenario. Any property setting for it is therefore not
diff --git a/Documentation/dev-eclipse.txt b/Documentation/dev-eclipse.txt
index 9596a55..742cf42 100644
--- a/Documentation/dev-eclipse.txt
+++ b/Documentation/dev-eclipse.txt
@@ -41,6 +41,37 @@
Filters on a folder, they will be overwritten the next time you run
`tools/eclipse/project.py`.
+=== Eclipse project on MacOS
+
+By default, bazel uses `/private/var/tmp` as the
+link:https://docs.bazel.build/versions/master/output_directories.html[outputRoot on MacOS].
+This means that the eclipse project will reference libraries stored under that directory.
+However, MacOS runs periodic cleanup task which deletes the content under `/private/var/tmp`
+which wasn't accessed or modified for some days, by default 3 days. This can lead to a broken
+Eclipse project as referenced libraries get deleted.
+
+There are two possibilities to mitigate this issue.
+
+==== Change the location of the bazel output directory
+On Linux, the output directory defaults to `$HOME/.cache/bazel` and the same can be configured
+on Mac too. Edit, or create, the `$HOME/.bazelrc` file and add the following line:
+----
+startup --output_user_root=/Users/johndoe/.cache/bazel
+----
+
+==== Increase the treshold for the cleanup of temporary files
+The default treshold for the cleanup can be overriden by creating a configuration file under
+`/etc/periodic.conf` and setting a larger value for the `daily_clean_tmps_days`.
+
+An example `/etc/periodic.conf` file:
+
+----
+# This file overrides the settings from /etc/defaults/periodic.conf
+daily_clean_tmps_days="45" # If not accessed for
+----
+
+For more details about the proposed workaround see link:https://superuser.com/a/187105[this post]
+
=== Eclipse project with custom plugins ===
To add custom plugins to the eclipse project add them to `tools/bzl/plugins.bzl`
diff --git a/Documentation/images/inline-edit-create-change-project-screen-dialog.png b/Documentation/images/inline-edit-create-change-project-screen-dialog.png
deleted file mode 100644
index ea5daa9..0000000
--- a/Documentation/images/inline-edit-create-change-project-screen-dialog.png
+++ /dev/null
Binary files differ
diff --git a/Documentation/images/inline-edit-create-change-project-screen.png b/Documentation/images/inline-edit-create-change-project-screen.png
deleted file mode 100644
index e9c7033..0000000
--- a/Documentation/images/inline-edit-create-change-project-screen.png
+++ /dev/null
Binary files differ
diff --git a/Documentation/images/inline-edit-create-follow-up-change.png b/Documentation/images/inline-edit-create-follow-up-change.png
deleted file mode 100644
index 3e81eee..0000000
--- a/Documentation/images/inline-edit-create-follow-up-change.png
+++ /dev/null
Binary files differ
diff --git a/Documentation/images/inline-edit-edit-in-diff-screen-patch-list.png b/Documentation/images/inline-edit-edit-in-diff-screen-patch-list.png
deleted file mode 100644
index bdbc59d..0000000
--- a/Documentation/images/inline-edit-edit-in-diff-screen-patch-list.png
+++ /dev/null
Binary files differ
diff --git a/Documentation/images/inline-edit-edit-in-patch-list.png b/Documentation/images/inline-edit-edit-in-patch-list.png
deleted file mode 100644
index 9a31e02..0000000
--- a/Documentation/images/inline-edit-edit-in-patch-list.png
+++ /dev/null
Binary files differ
diff --git a/Documentation/index.txt b/Documentation/index.txt
index 4fb977a..8f36ecc 100644
--- a/Documentation/index.txt
+++ b/Documentation/index.txt
@@ -69,6 +69,7 @@
. link:cmd-index.html[Command Line Tools]
. link:config-plugins.html#replication[Replication]
. link:config-plugins.html[Plugins]
+. link:logs.html[Log Files]
. link:metrics.html[Metrics]
. link:config-reverseproxy.html[Reverse Proxy]
. link:config-auto-site-initialization.html[Automatic Site Initialization on Startup]
diff --git a/Documentation/logs.txt b/Documentation/logs.txt
new file mode 100644
index 0000000..6624366
--- /dev/null
+++ b/Documentation/logs.txt
@@ -0,0 +1,165 @@
+= Gerrit Code Review - Logs
+
+Gerrit writes log files in the `$site_path/logs/` folder tracking requests,
+background and plugin activity and errors. By default logs are written in
+link:config-gerrit.html#log.textLogging[text format], optionally in
+link:config-gerrit.html#log.jsonLogging[JSON format].
+By default log files are link:config-gerrit.html#log.compress[compressed]
+at server startup and then daily at 11pm and
+link:config-gerrit.html#log.rotate[rotated] every midnight.
+
+== Time format
+
+For all timestamps the format `[yyyy-MM-dd'T'HH:mm:ss,SSSXXX]` is used.
+This format is both link:https://www.w3.org/TR/NOTE-datetime[ISO 8601] and
+link:https://tools.ietf.org/html/rfc3339[RFC3339] compatible.
+
+== Logs
+
+The following logs can be written.
+
+=== HTTPD Log
+
+The httpd log tracks HTTP requests processed by Gerrit's http daemon
+and is written to `$site_path/logs/httpd_log`. Enabled or disabled via the
+link:config-gerrit.html#httpd.requestLog[httpd.requestLog] option.
+
+Format is an enhanced
+link:https://httpd.apache.org/docs/2.4/logs.html#combined[NCSA combined log],
+if a log field is not present, a "-" is substituted:
+
+* `host`: The IP address of the HTTP client that made the HTTP resource request.
+ If you are using a reverse proxy it depends on the proxy configuration if the
+ proxy IP address or the client IP address is logged.
+* `[thread name]`: name of the Java thread executing the request.
+* `remote logname`: the identifier used to
+ link: https://tools.ietf.org/html/rfc1413[identify the client making the HTTP request],
+ Gerrit always logs a dash `-`.
+* `username`: the username used by the client for authentication. "-" for
+ anonymous requests.
+* `[date:time]`: The date and time stamp of the HTTP request.
+ The time that the request was received.
+* `request`: The request line from the client is given in double quotes.
+** the HTTP method used by the client.
+** the resource the client requested.
+** the protocol/version used by the client.
+* `statuscode`: the link:https://tools.ietf.org/html/rfc2616#section-10[HTTP status code]
+ that the server sent back to the client.
+* `response size`: the number of bytes of data transferred as part of the HTTP
+ response, not including the HTTP header.
+* `latency`: response time in milliseconds.
+* `referer`: the `Referer` HTTP request header. This gives the site that
+ the client reports having been referred from.
+* `client agent`: the client agent which sent the request.
+
+Example:
+```
+12.34.56.78 [HTTP-4136374] - johndoe [28/Aug/2020:10:02:20 +0200] "GET /a/plugins/metrics-reporter-prometheus/metrics HTTP/1.1" 200 1247498 1900 - "Prometheus/2.13.1"
+```
+
+=== SSHD Log
+
+The sshd log tracks ssh requests processed by Gerrit's ssh daemon
+and is written to `$site_path/logs/sshd_log`. Enabled or disabled
+via option link:config-gerrit.html#sshd.requestLog[sshd.requestLog].
+
+Log format:
+
+* `[date time]`: The time that the request was received.
+* `sessionid`: hexadecimal session identifier, all requests of the
+ same connection share the same sessionid. Gerrit does not support multiplexing multiple
+ sessions on the same connection. Grep the log file using the sessionid as filter to
+ get all requests from that session.
+* `[thread name]`: name of the Java thread executing the request.
+* `username`: the username used by the client for authentication.
+* `a/accountid`: identifier of the Gerrit account which is logged on.
+* `operation`: the operation being executed via ssh.
+** `LOGIN FROM <host>`: login and start new SSH session from the given host.
+** `AUTH FAILURE FROM <host> <message>`: failed authentication from given host and cause of failure.
+** `LOGOUT`: logout and terminate SSH session.
+** `git-upload-pack.<projectname>`: git fetch or clone command for given project.
+** `git-receive-pack.<projectname>`: git push command for given project.
+** Gerrit ssh commands which may be logged in this field are documented
+ link:cmd-index.html#_server[here].
+* `wait`: command wait time, time in milliseconds the command waited for an execution thread.
+* `exec`: command execution time, time in milliseconds to execute the command.
+* `status`: status code. 0 means success, any other value is an error.
+
+The `git-upload-pack` command provides the following additional fields after the `exec`
+and before the `status` field. All times are in milliseconds. Fields are -1 if not available
+when the upload-pack request returns an empty result since the client's repository was up to date:
+
+* `time negotiating`: time for negotiating which objects need to be transferred.
+* `time searching for reuse`: time jgit searched for deltas which can be reused.
+ That is the time spent matching existing representations against objects that
+ will be transmitted, or that the client can be assumed to already have.
+* `time searching for sizes`: time jgit was searching for sizes of all objects that
+ will enter the delta compression search window. The sizes need to
+ be known to better match similar objects together and improve
+ delta compression ratios.
+* `time counting`: time jgit spent enumerating the objects that need to
+ be included in the output. This time includes any restarts that
+ occur when a cached pack is selected for reuse.
+* `time compressing`: time jgit was compressing objects. This is observed
+ wall-clock time and does not accurately track CPU time used when
+ multiple threads were used to perform the delta compression.
+* `time writing`: time jgit needed to write packfile, from start of
+ header until end of trailer. The transfer speed can be
+ approximated by dividing `total bytes` by this value.
+* `total time in UploadPack`: total time jgit spent in upload-pack.
+* `bitmap index misses`: number of misses when trying to use bitmap index,
+ -1 means no bitmap index available. This is the count of objects that
+ needed to be discovered through an object walk because they were not found
+ in bitmap indices.
+* `total deltas`: total number of deltas transferred. This may be lower than the actual
+ number of deltas if a cached pack was reused.
+* `total objects`: total number of objects transferred. This total includes
+ the value of `total deltas`.
+* `total bytes`: total number of bytes transferred. This size includes the pack
+ header, trailer, thin pack, and reused cached packs.
+* `client agent`: the client agent and version which sent the request.
+
+Example: a CI system established a SSH connection, sent an upload-pack command (git fetch) and closed the connection:
+```
+[2020-08-28 11:00:01,391 +0200] 8a154cae [sshd-SshServer[570fc452]-nio2-thread-299] voter a/1000023 LOGIN FROM 12.34.56.78
+[2020-08-28 11:00:01,556 +0200] 8a154cae [SSH git-upload-pack /AP/ajs/jpaas-msg-svc.git (voter)] voter a/1000056 git-upload-pack./demo/project.git 0ms 115ms 92ms 1ms 0ms 6ms 0ms 0ms 7ms 3 10 26 2615 0 git/2.26.2
+[2020-08-28 11:00:01,583 +0200] 8a154cae [sshd-SshServer[570fc452]-nio2-thread-168] voter a/1000023 LOGOUT
+```
+
+=== Error Log
+
+The error log tracks errors and stack traces and is written to
+`$site_path/logs/error_log`.
+
+Log format:
+
+* `[date time]`: The time that the request was received.
+* `[thread name]`: : name of the Java thread executing the request.
+* `level`: log level (ERROR, WARN, INFO, DEBUG).
+* `logger`: name of the logger.
+* `message`: log message.
+* `stacktrace`: Java stacktrace when an execption was caught, usually spans multiple lines.
+
+=== GC Log
+
+The gc log tracks git garbage collection running in a background thread
+if enabled and is written to `$site_path/logs/gc_log`.
+
+Log format:
+
+* `[date time]`: The time that the request was received.
+* `level`: log level (ERROR, WARN, INFO, DEBUG).
+* `message`: log message.
+
+=== Plugin Logs
+
+Some plugins write their own log file.
+E.g. the replication plugin writes its log to `$site_path/logs/replication_log`.
+Refer to each plugin's documentation for more details on their logs.
+
+GERRIT
+------
+Part of link:index.html[Gerrit Code Review]
+
+SEARCHBOX
+---------
diff --git a/Documentation/rest-api-changes.txt b/Documentation/rest-api-changes.txt
index 4227655..e09d936 100644
--- a/Documentation/rest-api-changes.txt
+++ b/Documentation/rest-api-changes.txt
@@ -5026,6 +5026,137 @@
}
----
+[[get-ported-comments]]
+=== Get Ported Comments
+--
+'GET /changes/link:#change-id[\{change-id\}]/revisions/link:#revision-id[\{revision-id\}]/ported_comments'
+--
+
+Ports comments of other revisions to the requested revision.
+
+Only comments added on earlier patchsets are ported. That set of comments is filtered even further
+due to some additional rules. Callers of this endpoint shouldn't rely on the exact logic of which
+comments are ported as that logic might change in the future. Instead, callers must be able to
+handle any smaller/larger set of comments returned by this endpoint.
+
+Typically, a comment thread is returned fully or excluded fully. However, draft comments and
+robot comments are ignored and not returned via this endpoint. Hence, it's possible to get ported
+comments from this endpoint which are a reply to a non-ported robot comment. Callers must be
+able to deal with this situation.
+
+The returned comments are organized in a map of file path to link:#comment-info[CommentInfo] entries
+in the same fashion as for the link:#list-comments[List Revision Comments] endpoint.
+The map is filled with the original comment attributes except for these attributes: `path`, `line`,
+and `range` point to the computed position in the target revision. If the exactly correct position
+can't be determined, those fields will be filled with the next best position. That can also mean
+not filling the `line` or `range` attribute anymore and thus converting the comment to a file
+comment (or even moving the comment to a different file or the patchset level). Callers of this
+endpoint must be able to deal with this and not rely on the original comment position.
+
+It's possible that this endpoint returns different link:#comment-info[CommentInfo] entries with
+the same comment UUID. This is not a bug but a feature. If a comment appears on a file which Gerrit
+recognizes as copied between patchsets, the ported version of this comment consists of two ported
+instances having the same UUID but different `file`/`line`/`range` positions. Callers must be able
+to handle this situation.
+
+Repeated calls of this endpoint might produce different results. Internal errors during the
+position computation are mapped to fallback locations for affected comments. Those errors might
+have vanished on later calls, upon which this endpoint returns the actually mapped position. In
+addition, comments can be deleted and draft comments can be published, upon which the set of ported
+comments may change.
+
+.Request
+----
+ GET /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/revisions/4/ported_comments/ HTTP/1.0
+----
+
+.Response
+----
+ HTTP/1.1 200 OK
+ Content-Disposition: attachment
+ Content-Type: application/json; charset=UTF-8
+
+ )]}'
+ {
+ "gerrit-server/src/main/java/com/google/gerrit/server/project/RefControl.java": [
+ {
+ "id": "TvcXrmjM",
+ "patch_set": 2,
+ "line": 23,
+ "message": "[nit] trailing whitespace",
+ "updated": "2013-02-26 15:40:43.986000000",
+ "author": {
+ "_account_id": 1000096,
+ "name": "John Doe",
+ "email": "john.doe@example.com"
+ },
+ "unresolved": true
+ },
+ {
+ "id": "TveXwFiA",
+ "patch_set": 2,
+ "line": 23,
+ "in_reply_to": "TvcXrmjM",
+ "message": "Done",
+ "updated": "2013-02-26 15:40:45.328000000",
+ "author": {
+ "_account_id": 1000097,
+ "name": "Jane Roe",
+ "email": "jane.roe@example.com"
+ },
+ "unresolved": true
+ }
+ ]
+ }
+----
+
+[[get-ported-drafts]]
+=== Get Ported Drafts
+--
+'GET /changes/link:#change-id[\{change-id\}]/revisions/link:#revision-id[\{revision-id\}]/ported_drafts'
+--
+
+Ports draft comments of other revisions to the requested revision.
+
+This endpoint behaves similarly to the link:#get-ported-comments[Get Ported Comments] endpoint.
+With this endpoint, only draft comments of the calling user are ported, though. If a draft comment
+is a reply to a published comment, only the ported draft comment is returned.
+
+Depending on the filtering rules, it's possible that this endpoint returns a draft comment which is
+a reply to a comment thread which is not returned by the
+link:#get-ported-comments[Get Ported Comments] endpoint. That's intended behavior. Callers must be
+able to handle this situation. The same holds for drafts which are a reply to a robot comment.
+
+Different than the link:#get-ported-comments[Get Ported Comments] endpoint, the `author` of the
+returned comments is not filled for this endpoint as only comments of the calling user are returned.
+
+.Request
+----
+ GET /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/revisions/674ac754f91e64a0efb8087e59a176484bd534d1/ported_drafts/ HTTP/1.0
+----
+
+.Response
+----
+ HTTP/1.1 200 OK
+ Content-Disposition: attachment
+ Content-Type: application/json; charset=UTF-8
+
+ )]}'
+ {
+ "gerrit-server/src/main/java/com/google/gerrit/server/project/RefControl.java": [
+ {
+ "id": "TveXwFiA",
+ "patch_set": 2,
+ "line": 23,
+ "in_reply_to": "TvcXrmjM",
+ "message": "Done",
+ "updated": "2013-02-26 15:40:45.328000000",
+ "unresolved": true
+ }
+ ]
+ }
+----
+
[[apply-fix]]
=== Apply Fix
--
diff --git a/WORKSPACE b/WORKSPACE
index 6be35cf..4c2fe35 100644
--- a/WORKSPACE
+++ b/WORKSPACE
@@ -219,15 +219,15 @@
sha1 = GUAVA_BIN_SHA1,
)
-CAFFEINE_VERS = "2.8.0"
+CAFFEINE_VERS = "2.8.5"
maven_jar(
name = "caffeine",
artifact = "com.github.ben-manes.caffeine:caffeine:" + CAFFEINE_VERS,
- sha1 = "6000774d7f8412ced005a704188ced78beeed2bb",
+ sha1 = "f0eafef6e1529a44e36549cd9d1fc06d3a57f384",
)
-CAFFEINE_GUAVA_SHA256 = "3a66ee3ec70971dee0bae6e56bda7b8742bc4bedd7489161bfbbaaf7137d89e1"
+CAFFEINE_GUAVA_SHA256 = "a7ce6d29c40bccd688815a6734070c55b20cd326351a06886a6144005aa32299"
# TODO(davido): Rename guava.jar to caffeine-guava.jar on fetch to prevent potential
# naming collision between caffeine guava adapter and guava library itself.
@@ -758,8 +758,8 @@
# Keep this version of Soy synchronized with the version used in Gitiles.
maven_jar(
name = "soy",
- artifact = "com.google.template:soy:2019-10-08",
- sha1 = "4518bf8bac2dbbed684849bc209c39c4cb546237",
+ artifact = "com.google.template:soy:2020-08-24",
+ sha1 = "e774bf5cc95923d2685292883fe219e231346e50",
)
maven_jar(
diff --git a/e2e-tests/build.sbt b/e2e-tests/build.sbt
index a322970..294212c 100644
--- a/e2e-tests/build.sbt
+++ b/e2e-tests/build.sbt
@@ -2,7 +2,6 @@
enablePlugins(GatlingPlugin)
-lazy val gatlingGitExtension = RootProject(uri("git://github.com/GerritForge/gatling-git.git"))
lazy val root = (project in file("."))
.settings(
inThisBuild(List(
@@ -12,8 +11,8 @@
)),
name := "gerrit",
libraryDependencies ++=
- gatling ++
+ gatling ++ gatlingGit ++
Seq("io.gatling" % "gatling-core" % GatlingVersion) ++
Seq("io.gatling" % "gatling-app" % GatlingVersion),
scalacOptions += "-language:postfixOps"
- ) dependsOn gatlingGitExtension
+ )
diff --git a/e2e-tests/project/Dependencies.scala b/e2e-tests/project/Dependencies.scala
index 63328f9..56ef740 100644
--- a/e2e-tests/project/Dependencies.scala
+++ b/e2e-tests/project/Dependencies.scala
@@ -2,9 +2,17 @@
object Dependencies {
val GatlingVersion = "3.2.0"
+ val GatlingGitVersion = "1.0.12"
lazy val gatling = Seq(
"io.gatling.highcharts" % "gatling-charts-highcharts",
"io.gatling" % "gatling-test-framework",
).map(_ % GatlingVersion % Test)
+
+ lazy val gatlingGit = Seq(
+ "com.gerritforge" %% "gatling-git" % GatlingGitVersion excludeAll(
+ ExclusionRule(organization = "io.gatling"),
+ ExclusionRule(organization = "io.gatling.highcharts")
+ )
+ )
}
diff --git a/e2e-tests/project/plugins.sbt b/e2e-tests/project/plugins.sbt
index 36cd201..9ed0f89 100644
--- a/e2e-tests/project/plugins.sbt
+++ b/e2e-tests/project/plugins.sbt
@@ -1 +1,2 @@
addSbtPlugin("io.gatling" % "gatling-sbt" % "3.0.0")
+addSbtPlugin("com.typesafe.sbteclipse" % "sbteclipse-plugin" % "5.2.4")
diff --git a/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/CreateProject-body.json b/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/CreateProject-body.json
index bcf4708..282ac99 100644
--- a/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/CreateProject-body.json
+++ b/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/CreateProject-body.json
@@ -1,3 +1,4 @@
{
- "create_empty_commit": "true"
+ "create_empty_commit": "true",
+ "parent": "${parent}"
}
diff --git a/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/CreateProject.json b/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/CreateProject.json
index cd90739..c141bb8 100644
--- a/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/CreateProject.json
+++ b/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/CreateProject.json
@@ -1,5 +1,6 @@
[
{
- "url": "HTTP_SCHEME://HOSTNAME:HTTP_PORT/a/projects/PROJECT"
+ "url": "HTTP_SCHEME://HOSTNAME:HTTP_PORT/a/projects/PROJECT",
+ "parent": "PARENT"
}
]
diff --git a/e2e-tests/src/test/scala/com/google/gerrit/scenarios/CreateProject.scala b/e2e-tests/src/test/scala/com/google/gerrit/scenarios/CreateProject.scala
index d631292..f2b3d12 100644
--- a/e2e-tests/src/test/scala/com/google/gerrit/scenarios/CreateProject.scala
+++ b/e2e-tests/src/test/scala/com/google/gerrit/scenarios/CreateProject.scala
@@ -28,7 +28,7 @@
val test: ScenarioBuilder = scenario(unique)
.feed(data)
- .exec(httpRequest.body(RawFileBody(body)).asJson)
+ .exec(httpRequest.body(ElFileBody(body)).asJson)
setUp(
test.inject(
diff --git a/e2e-tests/src/test/scala/com/google/gerrit/scenarios/GerritSimulation.scala b/e2e-tests/src/test/scala/com/google/gerrit/scenarios/GerritSimulation.scala
index 4832392..679320d 100644
--- a/e2e-tests/src/test/scala/com/google/gerrit/scenarios/GerritSimulation.scala
+++ b/e2e-tests/src/test/scala/com/google/gerrit/scenarios/GerritSimulation.scala
@@ -67,6 +67,8 @@
case ("number", number) =>
val precedes = replaceKeyWith("_number", 0, number.toString)
replaceProperty("number", 1, precedes)
+ case ("parent", parent) =>
+ replaceProperty("parent", "All-Projects", parent.toString)
case ("project", project) =>
var precedes = replaceKeyWith("_project", name, project.toString)
precedes = replaceOverride(precedes)
diff --git a/java/com/google/gerrit/acceptance/testsuite/change/PerPatchsetOperationsImpl.java b/java/com/google/gerrit/acceptance/testsuite/change/PerPatchsetOperationsImpl.java
index 20f9c49..465c419 100644
--- a/java/com/google/gerrit/acceptance/testsuite/change/PerPatchsetOperationsImpl.java
+++ b/java/com/google/gerrit/acceptance/testsuite/change/PerPatchsetOperationsImpl.java
@@ -173,11 +173,13 @@
short side = commentCreation.side().orElse(CommentSide.PATCHSET_COMMIT).getNumericSide();
Boolean unresolved = commentCreation.unresolved().orElse(null);
String parentUuid = commentCreation.parentUuid().orElse(null);
+ Timestamp createdOn =
+ commentCreation.createdOn().map(Timestamp::from).orElse(context.getWhen());
HumanComment newComment =
commentsUtil.newHumanComment(
context.getNotes(),
context.getUser(),
- context.getWhen(),
+ createdOn,
filePath,
patchsetId,
side,
diff --git a/java/com/google/gerrit/acceptance/testsuite/change/TestCommentCreation.java b/java/com/google/gerrit/acceptance/testsuite/change/TestCommentCreation.java
index 2f4ddd0..2031bde 100644
--- a/java/com/google/gerrit/acceptance/testsuite/change/TestCommentCreation.java
+++ b/java/com/google/gerrit/acceptance/testsuite/change/TestCommentCreation.java
@@ -21,6 +21,9 @@
import com.google.gerrit.entities.Account;
import com.google.gerrit.entities.Comment;
import com.google.gerrit.entities.Patch;
+import java.time.Instant;
+import java.time.LocalDateTime;
+import java.time.ZoneOffset;
import java.util.Optional;
/**
@@ -49,6 +52,8 @@
public abstract Optional<Account.Id> author();
+ public abstract Optional<Instant> createdOn();
+
abstract Comment.Status status();
abstract ThrowingFunction<TestCommentCreation, String> commentCreator();
@@ -175,6 +180,22 @@
public abstract Builder author(Account.Id accountId);
/**
+ * Creation time of the comment. Like {@link #createdOn(Instant)} but with an arbitrary, fixed
+ * time zone (-> deterministic test execution).
+ */
+ public Builder createdOn(LocalDateTime createdOn) {
+ // We don't care about the exact time zone in most tests, just that it's fixed so that tests
+ // are deterministic.
+ return createdOn(createdOn.atZone(ZoneOffset.UTC).toInstant());
+ }
+
+ /**
+ * Creation time of the comment. This may also lie in the past or future. Comments stored in
+ * NoteDb support only second precision.
+ */
+ public abstract Builder createdOn(Instant createdOn);
+
+ /**
* Status of the comment. Hidden in the API surface. Use {@link
* PerPatchsetOperations#newComment()} or {@link PerPatchsetOperations#newDraftComment()}
* depending on which type of comment you want to create.
diff --git a/java/com/google/gerrit/httpd/raw/IndexHtmlUtil.java b/java/com/google/gerrit/httpd/raw/IndexHtmlUtil.java
index 6f8a863..77d02c1 100644
--- a/java/com/google/gerrit/httpd/raw/IndexHtmlUtil.java
+++ b/java/com/google/gerrit/httpd/raw/IndexHtmlUtil.java
@@ -68,7 +68,7 @@
Set<String> enabledExperiments = experimentData(urlParameterMap);
if (!enabledExperiments.isEmpty()) {
- data.put("enabledExperiments", serializeObject(GSON, enabledExperiments));
+ data.put("enabledExperiments", serializeObject(GSON, enabledExperiments).toString());
}
return data.build();
}
diff --git a/java/com/google/gerrit/server/CommentsUtil.java b/java/com/google/gerrit/server/CommentsUtil.java
index b83eae8..4d5778a 100644
--- a/java/com/google/gerrit/server/CommentsUtil.java
+++ b/java/com/google/gerrit/server/CommentsUtil.java
@@ -21,7 +21,6 @@
import static java.util.stream.Collectors.toList;
import com.google.common.collect.ComparisonChain;
-import com.google.common.collect.ImmutableSet;
import com.google.common.collect.Lists;
import com.google.common.collect.Ordering;
import com.google.gerrit.common.Nullable;
@@ -51,12 +50,8 @@
import java.sql.Timestamp;
import java.util.ArrayList;
import java.util.Collection;
-import java.util.HashSet;
import java.util.List;
-import java.util.Map;
import java.util.Optional;
-import java.util.Set;
-import java.util.stream.Collectors;
import org.eclipse.jgit.lib.ObjectId;
import org.eclipse.jgit.lib.Ref;
import org.eclipse.jgit.lib.Repository;
@@ -354,43 +349,6 @@
update.deleteCommentByRewritingHistory(commentKey.uuid, newMessage);
}
- /**
- * Gets all of the {@link HumanComment} in the comment threads that received a reply.
- *
- * @param changeNotes notes of this change.
- * @param newComments set of all the new comments added on the change by the current user.
- * @return set of all comments in the comments thread that received a reply.
- */
- public Set<HumanComment> getAllHumanCommentsInCommentThreads(
- ChangeNotes changeNotes, ImmutableSet<HumanComment> newComments) {
- Map<String, HumanComment> uuidToComment =
- publishedHumanCommentsByChange(changeNotes).stream()
- .collect(Collectors.toMap(c -> c.key.uuid, c -> c));
-
- // Copy the set so that it won't be mutated.
- List<HumanComment> toTraverse = new ArrayList<>(newComments);
- Set<String> seen = new HashSet<>();
- Set<HumanComment> allCommentsInCommentThreads = new HashSet<>();
- while (!toTraverse.isEmpty()) {
- HumanComment current = toTraverse.remove(0);
- allCommentsInCommentThreads.add(current);
-
- if (current.parentUuid != null) {
- HumanComment parent = uuidToComment.get(current.parentUuid);
- if (parent == null) {
- // If we can't find the parent within the human comments, the parent must be a robot
- // comment and can be ignored.
- continue;
- }
- if (!seen.contains(current.parentUuid)) {
- toTraverse.add(parent);
- seen.add(current.parentUuid);
- }
- }
- }
- return allCommentsInCommentThreads;
- }
-
private static List<HumanComment> commentsOnFile(
Collection<HumanComment> allComments, String file) {
List<HumanComment> result = new ArrayList<>(allComments.size());
diff --git a/java/com/google/gerrit/server/change/CommentThread.java b/java/com/google/gerrit/server/change/CommentThread.java
new file mode 100644
index 0000000..7b729d2
--- /dev/null
+++ b/java/com/google/gerrit/server/change/CommentThread.java
@@ -0,0 +1,69 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.change;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.base.Preconditions;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.Iterables;
+import com.google.gerrit.entities.Comment;
+import java.util.List;
+
+/**
+ * Representation of a comment thread.
+ *
+ * <p>A comment thread consists of at least one comment.
+ *
+ * @param <T> type of comments in the thread. Can also be {@link Comment} if the thread mixes
+ * comments of different types.
+ */
+@AutoValue
+public abstract class CommentThread<T extends Comment> {
+
+ /** Comments in the thread in exactly the order they appear in the thread. */
+ public abstract ImmutableList<T> comments();
+
+ /** Whether the whole thread is considered as unresolved. */
+ public boolean unresolved() {
+ return Iterables.getLast(comments()).unresolved;
+ }
+
+ public static <T extends Comment> Builder<T> builder() {
+ return new AutoValue_CommentThread.Builder<>();
+ }
+
+ @AutoValue.Builder
+ public abstract static class Builder<T extends Comment> {
+
+ public abstract Builder<T> comments(List<T> value);
+
+ public Builder<T> addComment(T comment) {
+ commentsBuilder().add(comment);
+ return this;
+ }
+
+ abstract ImmutableList.Builder<T> commentsBuilder();
+
+ abstract ImmutableList<T> comments();
+
+ abstract CommentThread<T> autoBuild();
+
+ public CommentThread<T> build() {
+ Preconditions.checkState(
+ !comments().isEmpty(), "A comment thread must contain at least one comment.");
+ return autoBuild();
+ }
+ }
+}
diff --git a/java/com/google/gerrit/server/change/CommentThreads.java b/java/com/google/gerrit/server/change/CommentThreads.java
new file mode 100644
index 0000000..b948737
--- /dev/null
+++ b/java/com/google/gerrit/server/change/CommentThreads.java
@@ -0,0 +1,137 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.change;
+
+import static com.google.common.collect.ImmutableSet.toImmutableSet;
+import static java.util.stream.Collectors.groupingBy;
+
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Streams;
+import com.google.gerrit.entities.Comment;
+import java.util.Comparator;
+import java.util.Map;
+import java.util.PriorityQueue;
+import java.util.Queue;
+import java.util.function.Function;
+
+/**
+ * Identifier of comment threads.
+ *
+ * <p>Comments are ordered into threads according to their parent relationship indicated via {@link
+ * Comment#parentUuid}. It's possible that two comments refer to the same parent, which especially
+ * happens when two persons reply in parallel. If such branches exist, we merge them into a flat
+ * list taking the comment creation date ({@link Comment#writtenOn} into account (but still
+ * preserving the general parent order). Remaining ties are resolved by using the natural order of
+ * the comment UUID, which is unique.
+ *
+ * @param <T> type of comments in the threads. Can also be {@link Comment} if the threads mix
+ * comments of different types.
+ */
+public class CommentThreads<T extends Comment> {
+
+ private final ImmutableMap<String, T> commentPerUuid;
+ private final Map<String, ImmutableSet<T>> childrenPerParent;
+
+ public CommentThreads(
+ ImmutableMap<String, T> commentPerUuid, Map<String, ImmutableSet<T>> childrenPerParent) {
+ this.commentPerUuid = commentPerUuid;
+ this.childrenPerParent = childrenPerParent;
+ }
+
+ public static <T extends Comment> CommentThreads<T> forComments(Iterable<T> comments) {
+ ImmutableMap<String, T> commentPerUuid =
+ Streams.stream(comments)
+ .distinct()
+ .collect(ImmutableMap.toImmutableMap(comment -> comment.key.uuid, Function.identity()));
+
+ Map<String, ImmutableSet<T>> childrenPerParent =
+ commentPerUuid.values().stream()
+ .filter(comment -> comment.parentUuid != null)
+ .collect(groupingBy(comment -> comment.parentUuid, toImmutableSet()));
+ return new CommentThreads<>(commentPerUuid, childrenPerParent);
+ }
+
+ /**
+ * Returns all comments organized into threads.
+ *
+ * <p>Comments appear only once.
+ */
+ public ImmutableSet<CommentThread<T>> getThreads() {
+ ImmutableSet<T> roots =
+ commentPerUuid.values().stream().filter(this::isRoot).collect(toImmutableSet());
+
+ return buildThreadsOf(roots);
+ }
+
+ /**
+ * Returns only the comment threads to which the specified comments are a reply.
+ *
+ * <p>If the specified child comments are part of the comments originally provided to {@link
+ * CommentThreads#forComments(Iterable)}, they will also appear in the returned comment threads.
+ * They don't need to be part of the originally provided comments, though, but should refer to one
+ * of these comments via their {@link Comment#parentUuid}. Child comments not referring to any
+ * known comments will be ignored.
+ *
+ * @param childComments comments for which the matching threads should be determined
+ * @return threads to which the provided child comments are a reply
+ */
+ public ImmutableSet<CommentThread<T>> getThreadsForChildren(Iterable<? extends T> childComments) {
+ ImmutableSet<T> relevantRoots =
+ Streams.stream(childComments)
+ .map(this::findRoot)
+ .filter(root -> commentPerUuid.containsKey(root.key.uuid))
+ .collect(toImmutableSet());
+ return buildThreadsOf(relevantRoots);
+ }
+
+ private T findRoot(T comment) {
+ T current = comment;
+ while (!isRoot(current)) {
+ current = commentPerUuid.get(current.parentUuid);
+ }
+ return current;
+ }
+
+ private boolean isRoot(T current) {
+ return current.parentUuid == null || !commentPerUuid.containsKey(current.parentUuid);
+ }
+
+ private ImmutableSet<CommentThread<T>> buildThreadsOf(ImmutableSet<T> roots) {
+ return roots.stream()
+ .map(root -> buildCommentThread(root, childrenPerParent))
+ .collect(toImmutableSet());
+ }
+
+ private static <T extends Comment> CommentThread<T> buildCommentThread(
+ T root, Map<String, ImmutableSet<T>> childrenPerParent) {
+ CommentThread.Builder<T> commentThread = CommentThread.builder();
+ // Expand comments gradually from the root. If there is more than one child per level, place the
+ // earlier-created child earlier in the thread. Break ties with the UUID to be deterministic.
+ Queue<T> unvisited =
+ new PriorityQueue<>(
+ Comparator.comparing((T comment) -> comment.writtenOn)
+ .thenComparing(comment -> comment.key.uuid));
+ unvisited.add(root);
+ while (!unvisited.isEmpty()) {
+ T nextComment = unvisited.remove();
+ commentThread.addComment(nextComment);
+ ImmutableSet<T> children =
+ childrenPerParent.getOrDefault(nextComment.key.uuid, ImmutableSet.of());
+ unvisited.addAll(children);
+ }
+ return commentThread.build();
+ }
+}
diff --git a/java/com/google/gerrit/server/query/change/ChangeData.java b/java/com/google/gerrit/server/query/change/ChangeData.java
index 7f7df8c..8301576 100644
--- a/java/com/google/gerrit/server/query/change/ChangeData.java
+++ b/java/com/google/gerrit/server/query/change/ChangeData.java
@@ -60,6 +60,8 @@
import com.google.gerrit.server.ReviewerStatusUpdate;
import com.google.gerrit.server.StarredChangesUtil;
import com.google.gerrit.server.StarredChangesUtil.StarRef;
+import com.google.gerrit.server.change.CommentThread;
+import com.google.gerrit.server.change.CommentThreads;
import com.google.gerrit.server.change.MergeabilityCache;
import com.google.gerrit.server.change.PureRevert;
import com.google.gerrit.server.config.AllUsersName;
@@ -790,47 +792,15 @@
List<Comment> comments =
Stream.concat(publishedComments().stream(), robotComments().stream()).collect(toList());
- // Build a map of uuid to list of direct descendants.
- Map<String, List<Comment>> forest = new HashMap<>();
- for (Comment comment : comments) {
- List<Comment> siblings = forest.get(comment.parentUuid);
- if (siblings == null) {
- siblings = new ArrayList<>();
- forest.put(comment.parentUuid, siblings);
- }
- siblings.add(comment);
- }
-
- // Find latest comment in each thread and apply to unresolved counter.
- int unresolved = 0;
- if (forest.containsKey(null)) {
- for (Comment root : forest.get(null)) {
- if (getLatestComment(forest, root).unresolved) {
- unresolved++;
- }
- }
- }
- unresolvedCommentCount = unresolved;
+ ImmutableSet<CommentThread<Comment>> commentThreads =
+ CommentThreads.forComments(comments).getThreads();
+ unresolvedCommentCount =
+ (int) commentThreads.stream().filter(CommentThread::unresolved).count();
}
return unresolvedCommentCount;
}
- protected Comment getLatestComment(Map<String, List<Comment>> forest, Comment root) {
- List<Comment> children = forest.get(root.key.uuid);
- if (children == null) {
- return root;
- }
- Comment latest = null;
- for (Comment comment : children) {
- Comment branchLatest = getLatestComment(forest, comment);
- if (latest == null || branchLatest.writtenOn.after(latest.writtenOn)) {
- latest = branchLatest;
- }
- }
- return latest;
- }
-
public void setUnresolvedCommentCount(Integer count) {
this.unresolvedCommentCount = count;
}
diff --git a/java/com/google/gerrit/server/restapi/change/CommentPorter.java b/java/com/google/gerrit/server/restapi/change/CommentPorter.java
index 3effa8c..05241e2 100644
--- a/java/com/google/gerrit/server/restapi/change/CommentPorter.java
+++ b/java/com/google/gerrit/server/restapi/change/CommentPorter.java
@@ -29,6 +29,8 @@
import com.google.gerrit.entities.Project;
import com.google.gerrit.extensions.client.DiffPreferencesInfo.Whitespace;
import com.google.gerrit.server.CommentsUtil;
+import com.google.gerrit.server.change.CommentThread;
+import com.google.gerrit.server.change.CommentThreads;
import com.google.gerrit.server.notedb.ChangeNotes;
import com.google.gerrit.server.patch.DiffMappings;
import com.google.gerrit.server.patch.GitPositionTransformer;
@@ -43,6 +45,7 @@
import com.google.gerrit.server.patch.PatchListNotAvailableException;
import com.google.inject.Inject;
import com.google.inject.Singleton;
+import java.util.Collection;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;
@@ -102,8 +105,18 @@
private ImmutableList<HumanComment> filterToRelevant(
List<HumanComment> allComments, PatchSet targetPatchset) {
- return allComments.stream()
- .filter(comment -> comment.key.patchSetId < targetPatchset.number())
+ ImmutableList<HumanComment> previousPatchsetsComments =
+ allComments.stream()
+ .filter(comment -> comment.key.patchSetId < targetPatchset.number())
+ .collect(toImmutableList());
+
+ ImmutableSet<CommentThread<HumanComment>> commentThreads =
+ CommentThreads.forComments(previousPatchsetsComments).getThreads();
+
+ return commentThreads.stream()
+ .filter(CommentThread::unresolved)
+ .map(CommentThread::comments)
+ .flatMap(Collection::stream)
.collect(toImmutableList());
}
diff --git a/java/com/google/gerrit/server/restapi/change/ReplyAttentionSetUpdates.java b/java/com/google/gerrit/server/restapi/change/ReplyAttentionSetUpdates.java
index 1f6574f..c523036 100644
--- a/java/com/google/gerrit/server/restapi/change/ReplyAttentionSetUpdates.java
+++ b/java/com/google/gerrit/server/restapi/change/ReplyAttentionSetUpdates.java
@@ -17,6 +17,8 @@
import static com.google.common.collect.ImmutableSet.toImmutableSet;
import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Sets;
+import com.google.common.collect.Sets.SetView;
import com.google.gerrit.entities.Account;
import com.google.gerrit.entities.HumanComment;
import com.google.gerrit.entities.PatchSet;
@@ -32,6 +34,8 @@
import com.google.gerrit.server.account.ServiceUserClassifier;
import com.google.gerrit.server.change.AddToAttentionSetOp;
import com.google.gerrit.server.change.AttentionSetUnchangedOp;
+import com.google.gerrit.server.change.CommentThread;
+import com.google.gerrit.server.change.CommentThreads;
import com.google.gerrit.server.change.RemoveFromAttentionSetOp;
import com.google.gerrit.server.notedb.ChangeNotes;
import com.google.gerrit.server.notedb.ChangeUpdate;
@@ -44,6 +48,7 @@
import com.google.inject.Inject;
import java.io.IOException;
import java.util.ArrayList;
+import java.util.Collection;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
@@ -208,19 +213,22 @@
/** Adds all authors of all comment threads that received a reply during this update */
private void addAllAuthorsOfCommentThreads(
BatchUpdate bu, ChangeNotes changeNotes, ImmutableSet<HumanComment> allNewComments) {
- Set<HumanComment> allCommentsInCommentThreads =
- commentsUtil.getAllHumanCommentsInCommentThreads(changeNotes, allNewComments);
- // Copy the set to make it mutable, so that we can delete users that were already added.
- Set<Account.Id> possibleUsersToAdd =
- new HashSet<>(approvalsUtil.getReviewers(changeNotes).all());
+ List<HumanComment> publishedComments = commentsUtil.publishedHumanCommentsByChange(changeNotes);
+ ImmutableSet<CommentThread<HumanComment>> repliedToCommentThreads =
+ CommentThreads.forComments(publishedComments).getThreadsForChildren(allNewComments);
- for (HumanComment comment : allCommentsInCommentThreads) {
- Account.Id author = comment.author.getId();
- if (possibleUsersToAdd.contains(author)) {
- addToAttentionSet(
- bu, changeNotes, author, "Someone else replied on a comment you posted", false);
- possibleUsersToAdd.remove(author);
- }
+ ImmutableSet<Account.Id> repliedToUsers =
+ repliedToCommentThreads.stream()
+ .map(CommentThread::comments)
+ .flatMap(Collection::stream)
+ .map(comment -> comment.author.getId())
+ .collect(toImmutableSet());
+ ImmutableSet<Account.Id> possibleUsersToAdd = approvalsUtil.getReviewers(changeNotes).all();
+ SetView<Account.Id> usersToAdd = Sets.intersection(possibleUsersToAdd, repliedToUsers);
+
+ for (Account.Id user : usersToAdd) {
+ addToAttentionSet(
+ bu, changeNotes, user, "Someone else replied on a comment you posted", false);
}
}
diff --git a/java/com/google/gerrit/sshd/SshDaemon.java b/java/com/google/gerrit/sshd/SshDaemon.java
index 5145c13..c43bf91 100644
--- a/java/com/google/gerrit/sshd/SshDaemon.java
+++ b/java/com/google/gerrit/sshd/SshDaemon.java
@@ -62,7 +62,9 @@
import java.util.Iterator;
import java.util.List;
import java.util.Set;
+import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
+import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import org.apache.mina.transport.socket.SocketSessionConfig;
import org.apache.sshd.common.BaseBuilder;
@@ -72,6 +74,8 @@
import org.apache.sshd.common.compression.BuiltinCompressions;
import org.apache.sshd.common.compression.Compression;
import org.apache.sshd.common.forward.DefaultForwarderFactory;
+import org.apache.sshd.common.future.CloseFuture;
+import org.apache.sshd.common.future.SshFutureListener;
import org.apache.sshd.common.io.AbstractIoServiceFactory;
import org.apache.sshd.common.io.IoAcceptor;
import org.apache.sshd.common.io.IoServiceFactory;
@@ -142,6 +146,7 @@
private final List<HostKey> hostKeys;
private volatile IoAcceptor daemonAcceptor;
private final Config cfg;
+ private final long gracefulStopTimeout;
@Inject
SshDaemon(
@@ -212,6 +217,8 @@
SshSessionBackend backend = cfg.getEnum("sshd", null, "backend", SshSessionBackend.NIO2);
boolean channelIdTracking = cfg.getBoolean("sshd", "enableChannelIdTracking", true);
+ gracefulStopTimeout = cfg.getTimeUnit("sshd", null, "gracefulStopTimeout", 0, TimeUnit.SECONDS);
+
System.setProperty(
IoServiceFactoryFactory.class.getName(),
backend == SshSessionBackend.MINA
@@ -341,6 +348,12 @@
public synchronized void stop() {
if (daemonAcceptor != null) {
try {
+ if (gracefulStopTimeout > 0) {
+ logger.atInfo().log(
+ "Stopping SSHD sessions gracefully with %d seconds timeout.", gracefulStopTimeout);
+ daemonAcceptor.unbind(daemonAcceptor.getBoundAddresses());
+ waitForSessionClose();
+ }
daemonAcceptor.close(true).await();
shutdownExecutors();
logger.atInfo().log("Stopped Gerrit SSHD");
@@ -352,6 +365,30 @@
}
}
+ private void waitForSessionClose() {
+ Collection<IoSession> ioSessions = daemonAcceptor.getManagedSessions().values();
+ CountDownLatch allSessionsClosed = new CountDownLatch(ioSessions.size());
+ for (IoSession io : ioSessions) {
+ logger.atFine().log("Waiting for session %s to stop.", io.getId());
+ io.addCloseFutureListener(
+ new SshFutureListener<CloseFuture>() {
+ @Override
+ public void operationComplete(CloseFuture future) {
+ allSessionsClosed.countDown();
+ }
+ });
+ }
+ try {
+ if (!allSessionsClosed.await(gracefulStopTimeout, TimeUnit.SECONDS)) {
+ logger.atWarning().log(
+ "Timeout waiting for SSH session to close. SSHD will be shut down immediately.");
+ }
+ } catch (InterruptedException e) {
+ logger.atWarning().withCause(e).log(
+ "Interrupted waiting for SSH-sessions to close. SSHD will be shut down immediately.");
+ }
+ }
+
private void shutdownExecutors() {
if (executor != null) {
executor.shutdownNow();
diff --git a/java/com/google/gerrit/util/logging/LogTimestampFormatter.java b/java/com/google/gerrit/util/logging/LogTimestampFormatter.java
index 9637b8b..cf071de 100644
--- a/java/com/google/gerrit/util/logging/LogTimestampFormatter.java
+++ b/java/com/google/gerrit/util/logging/LogTimestampFormatter.java
@@ -24,7 +24,7 @@
/** Formatter for timestamps used in log entries. */
public class LogTimestampFormatter {
- public static final String TIMESTAMP_FORMAT = "yyyy-MM-dd'T'HH:mm:ss.SSSZ";
+ public static final String TIMESTAMP_FORMAT = "yyyy-MM-dd'T'HH:mm:ss.SSSXXX";
private final DateTimeFormatter dateFormatter;
private final ZoneOffset timeOffset;
diff --git a/javatests/com/google/gerrit/acceptance/api/revision/PortedCommentsIT.java b/javatests/com/google/gerrit/acceptance/api/revision/PortedCommentsIT.java
index 702602b..783fa49 100644
--- a/javatests/com/google/gerrit/acceptance/api/revision/PortedCommentsIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/revision/PortedCommentsIT.java
@@ -27,6 +27,7 @@
import com.google.gerrit.acceptance.AbstractDaemonTest;
import com.google.gerrit.acceptance.testsuite.account.AccountOperations;
import com.google.gerrit.acceptance.testsuite.change.ChangeOperations;
+import com.google.gerrit.acceptance.testsuite.change.TestCommentCreation;
import com.google.gerrit.acceptance.testsuite.change.TestPatchset;
import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
import com.google.gerrit.entities.Account;
@@ -37,10 +38,11 @@
import com.google.gerrit.extensions.restapi.RestApiException;
import com.google.gerrit.truth.NullAwareCorrespondence;
import com.google.inject.Inject;
+import java.time.LocalDateTime;
+import java.time.ZoneOffset;
import java.util.Collection;
import java.util.List;
import java.util.Map;
-import org.junit.Ignore;
import org.junit.Test;
public class PortedCommentsIT extends AbstractDaemonTest {
@@ -57,9 +59,9 @@
PatchSet.Id patchset2Id = changeOps.change(changeId).newPatchset().create();
PatchSet.Id patchset3Id = changeOps.change(changeId).newPatchset().create();
// Add comments.
- String comment1Uuid = changeOps.change(changeId).patchset(patchset1Id).newComment().create();
- changeOps.change(changeId).patchset(patchset2Id).newComment().create();
- changeOps.change(changeId).patchset(patchset3Id).newComment().create();
+ String comment1Uuid = newComment(patchset1Id).create();
+ newComment(patchset2Id).create();
+ newComment(patchset3Id).create();
List<CommentInfo> portedComments = flatten(getPortedComments(patchset2Id));
@@ -75,8 +77,8 @@
PatchSet.Id patchset3Id = changeOps.change(changeId).newPatchset().create();
PatchSet.Id patchset4Id = changeOps.change(changeId).newPatchset().create();
// Add comments.
- String comment1Uuid = changeOps.change(changeId).patchset(patchset1Id).newComment().create();
- String comment3Uuid = changeOps.change(changeId).patchset(patchset3Id).newComment().create();
+ String comment1Uuid = newComment(patchset1Id).create();
+ String comment3Uuid = newComment(patchset3Id).create();
List<CommentInfo> portedComments = flatten(getPortedComments(patchset4Id));
@@ -92,8 +94,8 @@
PatchSet.Id patchset1Id = changeOps.change(changeId).currentPatchset().get().patchsetId();
PatchSet.Id patchset2Id = changeOps.change(changeId).newPatchset().create();
// Add comments.
- String comment1Uuid = changeOps.change(changeId).patchset(patchset1Id).newComment().create();
- String comment2Uuid = changeOps.change(changeId).patchset(patchset1Id).newComment().create();
+ String comment1Uuid = newComment(patchset1Id).create();
+ String comment2Uuid = newComment(patchset1Id).create();
List<CommentInfo> portedComments = flatten(getPortedComments(patchset2Id));
@@ -109,21 +111,9 @@
PatchSet.Id patchset1Id = changeOps.change(changeId).currentPatchset().get().patchsetId();
PatchSet.Id patchset2Id = changeOps.change(changeId).newPatchset().create();
// Add comments.
- String rootCommentUuid = changeOps.change(changeId).patchset(patchset1Id).newComment().create();
- String child1CommentUuid =
- changeOps
- .change(changeId)
- .patchset(patchset1Id)
- .newComment()
- .parentUuid(rootCommentUuid)
- .create();
- String child2CommentUuid =
- changeOps
- .change(changeId)
- .patchset(patchset1Id)
- .newComment()
- .parentUuid(child1CommentUuid)
- .create();
+ String rootCommentUuid = newComment(patchset1Id).create();
+ String child1CommentUuid = newComment(patchset1Id).parentUuid(rootCommentUuid).create();
+ String child2CommentUuid = newComment(patchset1Id).parentUuid(child1CommentUuid).create();
List<CommentInfo> portedComments = flatten(getPortedComments(patchset2Id));
@@ -133,17 +123,14 @@
}
@Test
- // TODO(aliceks): Filter out unresolved comment threads.
- @Ignore
- public void onlyUnresolvedCommentsArePorted() throws Exception {
+ public void onlyUnresolvedPublishedCommentsArePorted() throws Exception {
// Set up change and patchsets.
Change.Id changeId = changeOps.newChange().create();
PatchSet.Id patchset1Id = changeOps.change(changeId).currentPatchset().get().patchsetId();
PatchSet.Id patchset2Id = changeOps.change(changeId).newPatchset().create();
// Add comments.
- changeOps.change(changeId).patchset(patchset1Id).newComment().resolved().create();
- String comment2Uuid =
- changeOps.change(changeId).patchset(patchset1Id).newComment().unresolved().create();
+ newComment(patchset1Id).resolved().create();
+ String comment2Uuid = newComment(patchset1Id).unresolved().create();
List<CommentInfo> portedComments = flatten(getPortedComments(patchset2Id));
@@ -151,33 +138,34 @@
}
@Test
- // TODO(aliceks): Filter out unresolved comment threads.
- @Ignore
+ public void onlyUnresolvedDraftCommentsArePorted() throws Exception {
+ Account.Id accountId = accountOps.newAccount().create();
+ // Set up change and patchsets.
+ Change.Id changeId = changeOps.newChange().create();
+ PatchSet.Id patchset1Id = changeOps.change(changeId).currentPatchset().get().patchsetId();
+ PatchSet.Id patchset2Id = changeOps.change(changeId).newPatchset().create();
+ // Add comments.
+ newDraftComment(patchset1Id).author(accountId).resolved().create();
+ String comment2Uuid = newDraftComment(patchset1Id).author(accountId).unresolved().create();
+
+ List<CommentInfo> portedComments =
+ flatten(getPortedDraftCommentsOfUser(patchset2Id, accountId));
+
+ assertThat(portedComments).comparingElementsUsing(hasUuid()).containsExactly(comment2Uuid);
+ }
+
+ @Test
public void unresolvedStateOfLastCommentInThreadMatters() throws Exception {
// Set up change and patchsets.
Change.Id changeId = changeOps.newChange().create();
PatchSet.Id patchset1Id = changeOps.change(changeId).currentPatchset().get().patchsetId();
PatchSet.Id patchset2Id = changeOps.change(changeId).newPatchset().create();
// Add comments.
- String rootComment1Uuid =
- changeOps.change(changeId).patchset(patchset1Id).newComment().resolved().create();
+ String rootComment1Uuid = newComment(patchset1Id).resolved().create();
String childComment1Uuid =
- changeOps
- .change(changeId)
- .patchset(patchset1Id)
- .newComment()
- .parentUuid(rootComment1Uuid)
- .unresolved()
- .create();
- String rootComment2Uuid =
- changeOps.change(changeId).patchset(patchset1Id).newComment().unresolved().create();
- changeOps
- .change(changeId)
- .patchset(patchset1Id)
- .newComment()
- .parentUuid(rootComment2Uuid)
- .resolved()
- .create();
+ newComment(patchset1Id).parentUuid(rootComment1Uuid).unresolved().create();
+ String rootComment2Uuid = newComment(patchset1Id).unresolved().create();
+ newComment(patchset1Id).parentUuid(rootComment2Uuid).resolved().create();
List<CommentInfo> portedComments = flatten(getPortedComments(patchset2Id));
@@ -192,24 +180,21 @@
Change.Id changeId = changeOps.newChange().create();
PatchSet.Id patchset1Id = changeOps.change(changeId).currentPatchset().get().patchsetId();
PatchSet.Id patchset2Id = changeOps.change(changeId).newPatchset().create();
- // Add comments.
- String rootCommentUuid =
- changeOps.change(changeId).patchset(patchset1Id).newComment().resolved().create();
+ // Add comments. Comments should be more than 1 second apart as NoteDb only supports second
+ // precision.
+ LocalDateTime now = LocalDateTime.now(ZoneOffset.UTC);
+ String rootCommentUuid = newComment(patchset1Id).resolved().createdOn(now).create();
String childComment1Uuid =
- changeOps
- .change(changeId)
- .patchset(patchset1Id)
- .newComment()
+ newComment(patchset1Id)
.parentUuid(rootCommentUuid)
.resolved()
+ .createdOn(now.plusSeconds(5))
.create();
String childComment2Uuid =
- changeOps
- .change(changeId)
- .patchset(patchset1Id)
- .newComment()
+ newComment(patchset1Id)
.parentUuid(rootCommentUuid)
.unresolved()
+ .createdOn(now.plusSeconds(10))
.create();
List<CommentInfo> portedComments = flatten(getPortedComments(patchset2Id));
@@ -220,6 +205,30 @@
}
@Test
+ public void unresolvedStateOfDraftCommentsIsIgnoredForPublishedComments() throws Exception {
+ Account.Id accountId = accountOps.newAccount().create();
+ // Set up change and patchsets.
+ Change.Id changeId = changeOps.newChange().create();
+ PatchSet.Id patchset1Id = changeOps.change(changeId).currentPatchset().get().patchsetId();
+ PatchSet.Id patchset2Id = changeOps.change(changeId).newPatchset().create();
+ // Add comments.
+ String rootComment1Uuid = newComment(patchset1Id).resolved().create();
+ newDraftComment(patchset1Id)
+ .author(accountId)
+ .parentUuid(rootComment1Uuid)
+ .unresolved()
+ .create();
+ String rootComment2Uuid = newComment(patchset1Id).unresolved().create();
+ newDraftComment(patchset1Id).author(accountId).parentUuid(rootComment2Uuid).resolved().create();
+
+ // Draft comments are only visible to their author.
+ requestScopeOps.setApiUser(accountId);
+ List<CommentInfo> portedComments = flatten(getPortedComments(patchset2Id));
+
+ assertThat(portedComments).comparingElementsUsing(hasUuid()).containsExactly(rootComment2Uuid);
+ }
+
+ @Test
public void draftCommentsAreNotPortedViaApiForPublishedComments() throws Exception {
Account.Id accountId = accountOps.newAccount().create();
// Set up change and patchsets.
@@ -227,7 +236,7 @@
PatchSet.Id patchset1Id = changeOps.change(changeId).currentPatchset().get().patchsetId();
PatchSet.Id patchset2Id = changeOps.change(changeId).newPatchset().create();
// Add draft comment.
- changeOps.change(changeId).patchset(patchset1Id).newDraftComment().author(accountId).create();
+ newDraftComment(patchset1Id).author(accountId).create();
// Draft comments are only visible to their author.
requestScopeOps.setApiUser(accountId);
@@ -244,7 +253,7 @@
PatchSet.Id patchset1Id = changeOps.change(changeId).currentPatchset().get().patchsetId();
PatchSet.Id patchset2Id = changeOps.change(changeId).newPatchset().create();
// Add comment.
- changeOps.change(changeId).patchset(patchset1Id).newComment().author(accountId).create();
+ newComment(patchset1Id).author(accountId).create();
List<CommentInfo> portedComments =
flatten(getPortedDraftCommentsOfUser(patchset2Id, accountId));
@@ -260,7 +269,7 @@
PatchSet.Id patchset1Id = changeOps.change(changeId).currentPatchset().get().patchsetId();
PatchSet.Id patchset2Id = changeOps.change(changeId).newPatchset().create();
// Add draft comment.
- changeOps.change(changeId).patchset(patchset1Id).newComment().author(accountId).create();
+ newComment(patchset1Id).author(accountId).create();
List<CommentInfo> portedComments =
flatten(getPortedDraftCommentsOfUser(patchset2Id, accountId));
@@ -277,7 +286,7 @@
PatchSet.Id patchset1Id = changeOps.change(changeId).currentPatchset().get().patchsetId();
PatchSet.Id patchset2Id = changeOps.change(changeId).newPatchset().create();
// Add draft comment.
- changeOps.change(changeId).patchset(patchset1Id).newComment().author(otherUserId).create();
+ newComment(patchset1Id).author(otherUserId).create();
List<CommentInfo> portedComments = flatten(getPortedDraftCommentsOfUser(patchset2Id, userId));
@@ -292,10 +301,7 @@
PatchSet.Id patchset2Id = changeOps.change(changeId).newPatchset().create();
// Add comments.
String rangeCommentUuid =
- changeOps
- .change(changeId)
- .patchset(patchset1Id)
- .newComment()
+ newComment(patchset1Id)
.message("Range comment")
.fromLine(1)
.charOffset(2)
@@ -304,30 +310,11 @@
.ofFile("myFile")
.create();
String lineCommentUuid =
- changeOps
- .change(changeId)
- .patchset(patchset1Id)
- .newComment()
- .message("Line comment")
- .onLine(1)
- .ofFile("myFile")
- .create();
+ newComment(patchset1Id).message("Line comment").onLine(1).ofFile("myFile").create();
String fileCommentUuid =
- changeOps
- .change(changeId)
- .patchset(patchset1Id)
- .newComment()
- .message("File comment")
- .onFileLevelOf("myFile")
- .create();
+ newComment(patchset1Id).message("File comment").onFileLevelOf("myFile").create();
String patchsetLevelCommentUuid =
- changeOps
- .change(changeId)
- .patchset(patchset1Id)
- .newComment()
- .message("Patchset-level comment")
- .onPatchsetLevel()
- .create();
+ newComment(patchset1Id).message("Patchset-level comment").onPatchsetLevel().create();
List<CommentInfo> portedComments = flatten(getPortedComments(patchset2Id));
@@ -344,8 +331,7 @@
PatchSet.Id patchset1Id = changeOps.change(changeId).currentPatchset().get().patchsetId();
PatchSet.Id patchset2Id = changeOps.change(changeId).newPatchset().create();
// Add comments.
- String commentUuid =
- changeOps.change(changeId).patchset(patchset1Id).newComment().onParentCommit().create();
+ String commentUuid = newComment(patchset1Id).onParentCommit().create();
List<CommentInfo> portedComments = flatten(getPortedComments(patchset2Id));
@@ -359,7 +345,7 @@
PatchSet.Id patchset1Id = changeOps.change(changeId).currentPatchset().get().patchsetId();
PatchSet.Id patchset2Id = changeOps.change(changeId).newPatchset().create();
// Add comment.
- String commentUuid = changeOps.change(changeId).patchset(patchset1Id).newComment().create();
+ String commentUuid = newComment(patchset1Id).create();
List<CommentInfo> portedComments = flatten(getPortedComments(patchset2Id));
@@ -373,7 +359,7 @@
PatchSet.Id patchset1Id = changeOps.change(changeId).currentPatchset().get().patchsetId();
PatchSet.Id patchset2Id = changeOps.change(changeId).newPatchset().create();
// Add comment.
- String commentUuid = changeOps.change(changeId).patchset(patchset1Id).newComment().create();
+ String commentUuid = newComment(patchset1Id).create();
CommentInfo portedComment = getPortedComment(patchset2Id, commentUuid);
@@ -388,13 +374,7 @@
PatchSet.Id patchset1Id = changeOps.change(changeId).currentPatchset().get().patchsetId();
PatchSet.Id patchset2Id = changeOps.change(changeId).newPatchset().create();
// Add comment.
- String commentUuid =
- changeOps
- .change(changeId)
- .patchset(patchset1Id)
- .newDraftComment()
- .author(authorId)
- .create();
+ String commentUuid = newDraftComment(patchset1Id).author(authorId).create();
Map<String, List<CommentInfo>> portedComments =
getPortedDraftCommentsOfUser(patchset2Id, authorId);
@@ -413,8 +393,7 @@
TestPatchset patchset1 = changeOps.change(changeId).currentPatchset().get();
PatchSet.Id patchset2Id = changeOps.change(changeId).newPatchset().create();
// Add comment.
- String commentUuid =
- changeOps.change(changeId).patchset(patchset1.patchsetId()).newComment().create();
+ String commentUuid = newComment(patchset1.patchsetId()).create();
CommentInfo portedComment = getPortedComment(patchset2Id, commentUuid);
@@ -428,13 +407,7 @@
TestPatchset patchset1 = changeOps.change(changeId).currentPatchset().get();
PatchSet.Id patchset2Id = changeOps.change(changeId).newPatchset().create();
// Add comment.
- String commentUuid =
- changeOps
- .change(changeId)
- .patchset(patchset1.patchsetId())
- .newComment()
- .message("My comment text")
- .create();
+ String commentUuid = newComment(patchset1.patchsetId()).message("My comment text").create();
CommentInfo portedComment = getPortedComment(patchset2Id, commentUuid);
@@ -448,15 +421,9 @@
TestPatchset patchset1 = changeOps.change(changeId).currentPatchset().get();
PatchSet.Id patchset2Id = changeOps.change(changeId).newPatchset().create();
// Add comments.
- String rootCommentUuid =
- changeOps.change(changeId).patchset(patchset1.patchsetId()).newComment().create();
+ String rootCommentUuid = newComment(patchset1.patchsetId()).create();
String childCommentUuid =
- changeOps
- .change(changeId)
- .patchset(patchset1.patchsetId())
- .newComment()
- .parentUuid(rootCommentUuid)
- .create();
+ newComment(patchset1.patchsetId()).parentUuid(rootCommentUuid).create();
CommentInfo portedComment = getPortedComment(patchset2Id, childCommentUuid);
@@ -471,8 +438,7 @@
PatchSet.Id patchset1Id = changeOps.change(changeId).currentPatchset().get().patchsetId();
PatchSet.Id patchset2Id = changeOps.change(changeId).newPatchset().create();
// Add comment.
- String commentUuid =
- changeOps.change(changeId).patchset(patchset1Id).newComment().author(authorId).create();
+ String commentUuid = newComment(patchset1Id).author(authorId).create();
CommentInfo portedComment = getPortedComment(patchset2Id, commentUuid);
@@ -487,13 +453,7 @@
PatchSet.Id patchset1Id = changeOps.change(changeId).currentPatchset().get().patchsetId();
PatchSet.Id patchset2Id = changeOps.change(changeId).newPatchset().create();
// Add comment.
- String commentUuid =
- changeOps
- .change(changeId)
- .patchset(patchset1Id)
- .newDraftComment()
- .author(authorId)
- .create();
+ String commentUuid = newDraftComment(patchset1Id).author(authorId).create();
Map<String, List<CommentInfo>> portedComments =
getPortedDraftCommentsOfUser(patchset2Id, authorId);
@@ -510,13 +470,7 @@
TestPatchset patchset1 = changeOps.change(changeId).currentPatchset().get();
PatchSet.Id patchset2Id = changeOps.change(changeId).newPatchset().create();
// Add comment.
- String commentUuid =
- changeOps
- .change(changeId)
- .patchset(patchset1.patchsetId())
- .newComment()
- .tag("My comment tag")
- .create();
+ String commentUuid = newComment(patchset1.patchsetId()).tag("My comment tag").create();
CommentInfo portedComment = getPortedComment(patchset2Id, commentUuid);
@@ -530,7 +484,7 @@
PatchSet.Id patchset1Id = changeOps.change(changeId).currentPatchset().get().patchsetId();
PatchSet.Id patchset2Id = changeOps.change(changeId).newPatchset().create();
// Add comment.
- String commentUuid = changeOps.change(changeId).patchset(patchset1Id).newComment().create();
+ String commentUuid = newComment(patchset1Id).create();
CommentInfo portedComment = getPortedComment(patchset2Id, commentUuid);
@@ -544,7 +498,7 @@
PatchSet.Id patchset1Id = changeOps.change(changeId).currentPatchset().get().patchsetId();
PatchSet.Id patchset2Id = changeOps.change(changeId).newPatchset().create();
// Add comment.
- String commentUuid = changeOps.change(changeId).patchset(patchset1Id).newComment().create();
+ String commentUuid = newComment(patchset1Id).create();
CommentInfo portedComment = getPortedComment(patchset2Id, commentUuid);
@@ -561,13 +515,7 @@
PatchSet.Id patchset1Id = changeOps.change(changeId).currentPatchset().get().patchsetId();
PatchSet.Id patchset2Id = changeOps.change(changeId).newPatchset().create();
// Add comment.
- String commentUuid =
- changeOps
- .change(changeId)
- .patchset(patchset1Id)
- .newComment()
- .onFileLevelOf("myFile")
- .create();
+ String commentUuid = newComment(patchset1Id).onFileLevelOf("myFile").create();
Map<String, List<CommentInfo>> portedComments = getPortedComments(patchset2Id);
@@ -591,10 +539,7 @@
.create();
// Add comment.
String commentUuid =
- changeOps
- .change(changeId)
- .patchset(patchset1Id)
- .newComment()
+ newComment(patchset1Id)
.fromLine(3)
.charOffset(2)
.toLine(4)
@@ -625,10 +570,7 @@
.create();
// Add comment.
String commentUuid =
- changeOps
- .change(changeId)
- .patchset(patchset1Id)
- .newComment()
+ newComment(patchset1Id)
.fromLine(3)
.charOffset(2)
.toLine(4)
@@ -654,10 +596,7 @@
changeOps.change(changeId).newPatchset().file("myFile").renameTo("newFileName").create();
// Add comment.
String commentUuid =
- changeOps
- .change(changeId)
- .patchset(patchset1Id)
- .newComment()
+ newComment(patchset1Id)
.fromLine(3)
.charOffset(2)
.toLine(4)
@@ -692,10 +631,7 @@
.create();
// Add comment.
String commentUuid =
- changeOps
- .change(changeId)
- .patchset(patchset1Id)
- .newComment()
+ newComment(patchset1Id)
.fromLine(3)
.charOffset(2)
.toLine(4)
@@ -735,10 +671,7 @@
.create();
// Add comment.
String commentUuid =
- changeOps
- .change(changeId)
- .patchset(patchset1Id)
- .newComment()
+ newComment(patchset1Id)
.fromLine(3)
.charOffset(2)
.toLine(4)
@@ -778,10 +711,7 @@
.create();
// Add comment.
String commentUuid =
- changeOps
- .change(changeId)
- .patchset(patchset1Id)
- .newComment()
+ newComment(patchset1Id)
.fromLine(3)
.charOffset(2)
.toLine(4)
@@ -810,10 +740,7 @@
.create();
// Add comment.
String commentUuid =
- changeOps
- .change(changeId)
- .patchset(patchset1Id)
- .newComment()
+ newComment(patchset1Id)
.fromLine(2)
.charOffset(2)
.toLine(3)
@@ -844,10 +771,7 @@
.create();
// Add comment.
String commentUuid =
- changeOps
- .change(changeId)
- .patchset(patchset1Id)
- .newComment()
+ newComment(patchset1Id)
.fromLine(1)
.charOffset(2)
.toLine(2)
@@ -878,10 +802,7 @@
.create();
// Add comment.
String commentUuid =
- changeOps
- .change(changeId)
- .patchset(patchset1Id)
- .newComment()
+ newComment(patchset1Id)
.fromLine(1)
.charOffset(2)
.toLine(3)
@@ -912,10 +833,7 @@
.create();
// Add comment.
String commentUuid =
- changeOps
- .change(changeId)
- .patchset(patchset1Id)
- .newComment()
+ newComment(patchset1Id)
.fromLine(1)
.charOffset(2)
.toLine(3)
@@ -944,10 +862,7 @@
.create();
// Add comment.
String commentUuid =
- changeOps
- .change(changeId)
- .patchset(patchset1Id)
- .newComment()
+ newComment(patchset1Id)
.fromLine(3)
.charOffset(2)
.toLine(3)
@@ -977,10 +892,7 @@
.create();
// Add comment.
String commentUuid =
- changeOps
- .change(changeId)
- .patchset(patchset1Id)
- .newComment()
+ newComment(patchset1Id)
.fromLine(2)
.charOffset(2)
.toLine(4)
@@ -1009,10 +921,7 @@
.create();
// Add comment.
String commentUuid =
- changeOps
- .change(changeId)
- .patchset(patchset1Id)
- .newComment()
+ newComment(patchset1Id)
.fromLine(3)
.charOffset(2)
.toLine(4)
@@ -1049,10 +958,7 @@
.create();
// Add comment.
String commentUuid =
- changeOps
- .change(changeId)
- .patchset(patchset1Id)
- .newComment()
+ newComment(patchset1Id)
.fromLine(2)
.charOffset(2)
.toLine(4)
@@ -1076,10 +982,7 @@
changeOps.change(changeId).newPatchset().file("myFile").delete().create();
// Add comment.
String commentUuid =
- changeOps
- .change(changeId)
- .patchset(patchset1Id)
- .newComment()
+ newComment(patchset1Id)
.fromLine(3)
.charOffset(2)
.toLine(4)
@@ -1108,14 +1011,7 @@
.content("Line 1\nLine 1.1\nLine 1.2\nLine 2\nLine 3\nLine 4\n")
.create();
// Add comment.
- String commentUuid =
- changeOps
- .change(changeId)
- .patchset(patchset1Id)
- .newComment()
- .onLine(3)
- .ofFile("myFile")
- .create();
+ String commentUuid = newComment(patchset1Id).onLine(3).ofFile("myFile").create();
CommentInfo portedComment = getPortedComment(patchset2Id, commentUuid);
@@ -1136,14 +1032,7 @@
.content("Line 2\nLine 3\nLine 4\n")
.create();
// Add comment.
- String commentUuid =
- changeOps
- .change(changeId)
- .patchset(patchset1Id)
- .newComment()
- .onLine(3)
- .ofFile("myFile")
- .create();
+ String commentUuid = newComment(patchset1Id).onLine(3).ofFile("myFile").create();
CommentInfo portedComment = getPortedComment(patchset2Id, commentUuid);
@@ -1159,14 +1048,7 @@
PatchSet.Id patchset2Id =
changeOps.change(changeId).newPatchset().file("myFile").renameTo("newFileName").create();
// Add comment.
- String commentUuid =
- changeOps
- .change(changeId)
- .patchset(patchset1Id)
- .newComment()
- .onLine(3)
- .ofFile("myFile")
- .create();
+ String commentUuid = newComment(patchset1Id).onLine(3).ofFile("myFile").create();
Map<String, List<CommentInfo>> portedComments = getPortedComments(patchset2Id);
@@ -1190,14 +1072,7 @@
PatchSet.Id patchset3Id =
changeOps.change(changeId).newPatchset().file("myFile").renameTo("newFileName").create();
// Add comment.
- String commentUuid =
- changeOps
- .change(changeId)
- .patchset(patchset1Id)
- .newComment()
- .onLine(3)
- .ofFile("myFile")
- .create();
+ String commentUuid = newComment(patchset1Id).onLine(3).ofFile("myFile").create();
Map<String, List<CommentInfo>> portedComments = getPortedComments(patchset3Id);
@@ -1227,14 +1102,7 @@
.content("Line 1\nLine 1.1\nLine 1.2\nLine 2\nLine 3\nLine 4\n")
.create();
// Add comment.
- String commentUuid =
- changeOps
- .change(changeId)
- .patchset(patchset1Id)
- .newComment()
- .onLine(3)
- .ofFile("myFile")
- .create();
+ String commentUuid = newComment(patchset1Id).onLine(3).ofFile("myFile").create();
Map<String, List<CommentInfo>> portedComments = getPortedComments(patchset2Id);
@@ -1261,14 +1129,7 @@
.content("Line 1\nLine two\nLine three\nLine 4\n")
.create();
// Add comment.
- String commentUuid =
- changeOps
- .change(changeId)
- .patchset(patchset1Id)
- .newComment()
- .onLine(2)
- .ofFile("myFile")
- .create();
+ String commentUuid = newComment(patchset1Id).onLine(2).ofFile("myFile").create();
Map<String, List<CommentInfo>> portedComments = getPortedComments(patchset2Id);
@@ -1291,14 +1152,7 @@
.content("Line 1\nLine 2\nLine three\nLine 4\n")
.create();
// Add comment.
- String commentUuid =
- changeOps
- .change(changeId)
- .patchset(patchset1Id)
- .newComment()
- .onLine(2)
- .ofFile("myFile")
- .create();
+ String commentUuid = newComment(patchset1Id).onLine(2).ofFile("myFile").create();
CommentInfo portedComment = getPortedComment(patchset2Id, commentUuid);
@@ -1319,14 +1173,7 @@
.content("Line 1\nLine 2\nSome completely\ndifferent\ncontent\n")
.create();
// Add comment.
- String commentUuid =
- changeOps
- .change(changeId)
- .patchset(patchset1Id)
- .newComment()
- .onLine(3)
- .ofFile("myFile")
- .create();
+ String commentUuid = newComment(patchset1Id).onLine(3).ofFile("myFile").create();
CommentInfo portedComment = getPortedComment(patchset2Id, commentUuid);
@@ -1347,14 +1194,7 @@
.content("Line 1\nLine 2\nSome completely\ndifferent\ncontent\n")
.create();
// Add comment.
- String commentUuid =
- changeOps
- .change(changeId)
- .patchset(patchset1Id)
- .newComment()
- .onLine(4)
- .ofFile("myFile")
- .create();
+ String commentUuid = newComment(patchset1Id).onLine(4).ofFile("myFile").create();
CommentInfo portedComment = getPortedComment(patchset2Id, commentUuid);
@@ -1375,14 +1215,7 @@
.content("Line 1\nLine 2\nLine three\nLine 4\n")
.create();
// Add comment.
- String commentUuid =
- changeOps
- .change(changeId)
- .patchset(patchset1Id)
- .newComment()
- .onLine(4)
- .ofFile("myFile")
- .create();
+ String commentUuid = newComment(patchset1Id).onLine(4).ofFile("myFile").create();
CommentInfo portedComment = getPortedComment(patchset2Id, commentUuid);
@@ -1398,14 +1231,7 @@
PatchSet.Id patchset2Id =
changeOps.change(changeId).newPatchset().file("myFile").delete().create();
// Add comment.
- String commentUuid =
- changeOps
- .change(changeId)
- .patchset(patchset1Id)
- .newComment()
- .onLine(3)
- .ofFile("myFile")
- .create();
+ String commentUuid = newComment(patchset1Id).onLine(3).ofFile("myFile").create();
Map<String, List<CommentInfo>> portedComments = getPortedComments(patchset2Id);
assertThatMap(portedComments).keys().containsExactly(Patch.PATCHSET_LEVEL);
@@ -1428,13 +1254,7 @@
.content("Line 1\nLine 1.1\nLine 1.2\nLine 2\nLine 3\nLine 4\n")
.create();
// Add comment.
- String commentUuid =
- changeOps
- .change(changeId)
- .patchset(patchset1Id)
- .newComment()
- .onFileLevelOf("myFile")
- .create();
+ String commentUuid = newComment(patchset1Id).onFileLevelOf("myFile").create();
CommentInfo portedComment = getPortedComment(patchset2Id, commentUuid);
@@ -1450,13 +1270,7 @@
PatchSet.Id patchset2Id =
changeOps.change(changeId).newPatchset().file("myFile").renameTo("newFileName").create();
// Add comment.
- String commentUuid =
- changeOps
- .change(changeId)
- .patchset(patchset1Id)
- .newComment()
- .onFileLevelOf("myFile")
- .create();
+ String commentUuid = newComment(patchset1Id).onFileLevelOf("myFile").create();
Map<String, List<CommentInfo>> portedComments = getPortedComments(patchset2Id);
@@ -1481,7 +1295,7 @@
.content("Line 1\nLine 2\nLine 3\nLine 4\n")
.create();
// Add comment.
- changeOps.change(changeId).patchset(patchset1Id).newComment().onFileLevelOf("myFile").create();
+ newComment(patchset1Id).onFileLevelOf("myFile").create();
Map<String, List<CommentInfo>> portedComments = getPortedComments(patchset2Id);
@@ -1499,13 +1313,7 @@
PatchSet.Id patchset2Id =
changeOps.change(changeId).newPatchset().file("myFile").delete().create();
// Add comment.
- String commentUuid =
- changeOps
- .change(changeId)
- .patchset(patchset1Id)
- .newComment()
- .onFileLevelOf("myFile")
- .create();
+ String commentUuid = newComment(patchset1Id).onFileLevelOf("myFile").create();
Map<String, List<CommentInfo>> portedComments = getPortedComments(patchset2Id);
assertThatMap(portedComments).keys().containsExactly(Patch.PATCHSET_LEVEL);
@@ -1528,7 +1336,7 @@
.content("Line 1\nLine 1.1\nLine 1.2\nLine 2\nLine 3\nLine 4\n")
.create();
// Add comment.
- changeOps.change(changeId).patchset(patchset1Id).newComment().onPatchsetLevel().create();
+ newComment(patchset1Id).onPatchsetLevel().create();
Map<String, List<CommentInfo>> portedComments = getPortedComments(patchset2Id);
@@ -1544,7 +1352,7 @@
PatchSet.Id patchset2Id =
changeOps.change(changeId).newPatchset().file("myFile").renameTo("newFileName").create();
// Add comment.
- changeOps.change(changeId).patchset(patchset1Id).newComment().onPatchsetLevel().create();
+ newComment(patchset1Id).onPatchsetLevel().create();
Map<String, List<CommentInfo>> portedComments = getPortedComments(patchset2Id);
@@ -1565,10 +1373,7 @@
.create();
// Add comment.
String commentUuid =
- changeOps
- .change(changeId)
- .patchset(patchset1Id)
- .newComment()
+ newComment(patchset1Id)
// The /COMMIT_MSG file has a header of 6 lines, so the summary line is in line 7.
// Place comment on 'Text 2' which is line 10.
.onLine(10)
@@ -1605,14 +1410,7 @@
changeOps.change(childChangeId).newPatchset().parent().patchset(parentPatchset2Id).create();
// Add comment.
String commentUuid =
- changeOps
- .change(childChangeId)
- .patchset(childPatchset1Id)
- .newComment()
- .onParentCommit()
- .onLine(1)
- .ofFile("myFile")
- .create();
+ newComment(childPatchset1Id).onParentCommit().onLine(1).ofFile("myFile").create();
CommentInfo portedComment = getPortedComment(childPatchset2Id, commentUuid);
@@ -1654,14 +1452,7 @@
.create();
// Add comment.
String commentUuid =
- changeOps
- .change(childChangeId)
- .patchset(childPatchset1Id)
- .newComment()
- .onParentCommit()
- .onLine(1)
- .ofFile("file1")
- .create();
+ newComment(childPatchset1Id).onParentCommit().onLine(1).ofFile("file1").create();
CommentInfo portedComment = getPortedComment(childPatchset2Id, commentUuid);
@@ -1703,14 +1494,7 @@
.create();
// Add comment.
String commentUuid =
- changeOps
- .change(childChangeId)
- .patchset(childPatchset1Id)
- .newComment()
- .onSecondParentCommit()
- .onLine(1)
- .ofFile("file2")
- .create();
+ newComment(childPatchset1Id).onSecondParentCommit().onLine(1).ofFile("file2").create();
CommentInfo portedComment = getPortedComment(childPatchset2Id, commentUuid);
@@ -1758,14 +1542,7 @@
.create();
// Add comment.
String commentUuid =
- changeOps
- .change(childChangeId)
- .patchset(childPatchset1Id)
- .newComment()
- .onAutoMergeCommit()
- .onLine(1)
- .ofFile("file1")
- .create();
+ newComment(childPatchset1Id).onAutoMergeCommit().onLine(1).ofFile("file1").create();
CommentInfo portedComment = getPortedComment(childPatchset2Id, commentUuid);
@@ -1776,6 +1553,22 @@
assertThat(portedComment).line().isGreaterThan(2);
}
+ private TestCommentCreation.Builder newComment(PatchSet.Id patchsetId) {
+ // Create unresolved comments by default as only those are ported. Tests get override the
+ // unresolved state by explicitly setting it.
+ return changeOps.change(patchsetId.changeId()).patchset(patchsetId).newComment().unresolved();
+ }
+
+ private TestCommentCreation.Builder newDraftComment(PatchSet.Id patchsetId) {
+ // Create unresolved comments by default as only those are ported. Tests get override the
+ // unresolved state by explicitly setting it.
+ return changeOps
+ .change(patchsetId.changeId())
+ .patchset(patchsetId)
+ .newDraftComment()
+ .unresolved();
+ }
+
private CommentInfo getPortedComment(PatchSet.Id patchsetId, String commentUuid)
throws RestApiException {
Map<String, List<CommentInfo>> portedComments = getPortedComments(patchsetId);
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/AttentionSetIT.java b/javatests/com/google/gerrit/acceptance/rest/change/AttentionSetIT.java
index b156d1b..ed4c33a 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/AttentionSetIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/AttentionSetIT.java
@@ -22,14 +22,19 @@
import com.google.common.collect.ImmutableMap;
import com.google.common.collect.ImmutableSet;
import com.google.common.collect.Iterables;
+import com.google.common.truth.Correspondence;
import com.google.gerrit.acceptance.AbstractDaemonTest;
import com.google.gerrit.acceptance.NoHttpd;
import com.google.gerrit.acceptance.PushOneCommit;
import com.google.gerrit.acceptance.TestAccount;
import com.google.gerrit.acceptance.UseClockStep;
+import com.google.gerrit.acceptance.testsuite.account.AccountOperations;
+import com.google.gerrit.acceptance.testsuite.change.ChangeOperations;
import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.Account;
import com.google.gerrit.entities.AttentionSetUpdate;
+import com.google.gerrit.entities.Change;
import com.google.gerrit.entities.Patch;
import com.google.gerrit.extensions.api.changes.AddReviewerInput;
import com.google.gerrit.extensions.api.changes.AttentionSetInput;
@@ -39,12 +44,17 @@
import com.google.gerrit.extensions.client.ReviewerState;
import com.google.gerrit.extensions.client.Side;
import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.query.change.InternalChangeQuery;
import com.google.gerrit.server.util.time.TimeUtil;
import com.google.gerrit.testing.FakeEmailSender;
import com.google.gerrit.testing.TestCommentHelper;
+import com.google.gerrit.truth.NullAwareCorrespondence;
import com.google.inject.Inject;
+import com.google.inject.Provider;
import java.time.Duration;
import java.time.Instant;
+import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.TimeUnit;
import java.util.function.LongSupplier;
@@ -56,9 +66,13 @@
@UseClockStep(clockStepUnit = TimeUnit.MINUTES)
public class AttentionSetIT extends AbstractDaemonTest {
+ @Inject private ChangeOperations changeOperations;
+ @Inject private AccountOperations accountOperations;
@Inject private RequestScopeOperations requestScopeOperations;
+
@Inject private FakeEmailSender email;
@Inject private TestCommentHelper testCommentHelper;
+ @Inject private Provider<InternalChangeQuery> changeQueryProvider;
/** Simulates a fake clock. Uses second granularity. */
private static class FakeClock implements LongSupplier {
@@ -165,7 +179,8 @@
assertThat(emailBody)
.contains(
user.fullName()
- + " removed themselves from the attention set of this change.\n The reason is: removed.");
+ + " removed themselves from the attention set of this change.\n"
+ + " The reason is: removed.");
}
@Test
@@ -611,7 +626,8 @@
assertThat(exception.getMessage())
.isEqualTo(
- "user can not be added/removed twice, and can not be added and removed at the same time");
+ "user can not be added/removed twice, and can not be added and removed at the same"
+ + " time");
}
@Test
@@ -627,7 +643,8 @@
assertThat(exception.getMessage())
.isEqualTo(
- "user can not be added/removed twice, and can not be added and removed at the same time");
+ "user can not be added/removed twice, and can not be added and removed at the same"
+ + " time");
}
@Test
@@ -663,7 +680,8 @@
assertThat(exception.getMessage())
.isEqualTo(
- "user can not be added/removed twice, and can not be added and removed at the same time");
+ "user can not be added/removed twice, and can not be added and removed at the same"
+ + " time");
}
@Test
@@ -958,6 +976,64 @@
}
@Test
+ public void reviewAddsAllUsersInCommentThreadEvenOfDifferentChildBranch() throws Exception {
+ Account.Id changeOwner = accountOperations.newAccount().create();
+ Change.Id changeId = changeOperations.newChange().owner(changeOwner).create();
+ Account.Id user1 = accountOperations.newAccount().create();
+ Account.Id user2 = accountOperations.newAccount().create();
+ Account.Id user3 = accountOperations.newAccount().create();
+ Account.Id user4 = accountOperations.newAccount().create();
+ // Add users as reviewers.
+ gApi.changes().id(changeId.get()).addReviewer(user1.toString());
+ gApi.changes().id(changeId.get()).addReviewer(user2.toString());
+ gApi.changes().id(changeId.get()).addReviewer(user3.toString());
+ gApi.changes().id(changeId.get()).addReviewer(user4.toString());
+ // Add a comment thread with branches. Such threads occur if people reply in parallel without
+ // having seen/loaded the reply of another person.
+ String root =
+ changeOperations.change(changeId).currentPatchset().newComment().author(user1).create();
+ String sibling1 =
+ changeOperations
+ .change(changeId)
+ .currentPatchset()
+ .newComment()
+ .author(user2)
+ .parentUuid(root)
+ .create();
+ String sibling2 =
+ changeOperations
+ .change(changeId)
+ .currentPatchset()
+ .newComment()
+ .author(user3)
+ .parentUuid(root)
+ .create();
+ changeOperations
+ .change(changeId)
+ .currentPatchset()
+ .newComment()
+ .author(user4)
+ .parentUuid(sibling2)
+ .create();
+ // Clear the attention set. Necessary as we used Gerrit APIs above which affect the attention
+ // set.
+ AttentionSetInput clearAttention = new AttentionSetInput("clear attention set");
+ gApi.changes().id(changeId.get()).attention(user1.toString()).remove(clearAttention);
+ gApi.changes().id(changeId.get()).attention(user2.toString()).remove(clearAttention);
+ gApi.changes().id(changeId.get()).attention(user3.toString()).remove(clearAttention);
+ gApi.changes().id(changeId.get()).attention(user4.toString()).remove(clearAttention);
+
+ requestScopeOperations.setApiUser(changeOwner);
+ // Simulate that this reply is a child of sibling1 and thus parallel to sibling2 and its child.
+ gApi.changes().id(changeId.get()).current().review(reviewInReplyToComment(sibling1));
+
+ List<AttentionSetUpdate> attentionSetUpdates = getAttentionSetUpdates(changeId);
+ assertThat(attentionSetUpdates)
+ .comparingElementsUsing(hasAccount())
+ .containsExactly(user1, user2, user3, user4);
+ }
+
+ @Test
public void reviewAddsAllUsersInCommentThreadWhenPostedAsDraft() throws Exception {
PushOneCommit.Result r = createChange();
requestScopeOperations.setApiUser(user.id());
@@ -1281,11 +1357,20 @@
private List<AttentionSetUpdate> getAttentionSetUpdatesForUser(
PushOneCommit.Result r, TestAccount account) {
- return r.getChange().attentionSet().stream()
- .filter(a -> a.account().get() == account.id().get())
+ return getAttentionSetUpdates(r.getChange().getId()).stream()
+ .filter(a -> a.account().equals(account.id()))
.collect(Collectors.toList());
}
+ private List<AttentionSetUpdate> getAttentionSetUpdates(Change.Id changeId) {
+ List<ChangeData> changeData = changeQueryProvider.get().byLegacyChangeId(changeId);
+ if (changeData.size() != 1) {
+ throw new IllegalStateException(
+ String.format("Not exactly one change found for ID %s.", changeId));
+ }
+ return new ArrayList<>(Iterables.getOnlyElement(changeData).attentionSet());
+ }
+
private ReviewInput reviewWithComment() {
return reviewInReplyToComment(null);
}
@@ -1301,4 +1386,8 @@
reviewInput.comments = ImmutableMap.of(Patch.COMMIT_MSG, ImmutableList.of(comment));
return reviewInput;
}
+
+ private Correspondence<AttentionSetUpdate, Account.Id> hasAccount() {
+ return NullAwareCorrespondence.transforming(AttentionSetUpdate::account, "hasAccount");
+ }
}
diff --git a/javatests/com/google/gerrit/acceptance/testsuite/change/PatchsetOperationsImplTest.java b/javatests/com/google/gerrit/acceptance/testsuite/change/PatchsetOperationsImplTest.java
index fa28396..c29cf99 100644
--- a/javatests/com/google/gerrit/acceptance/testsuite/change/PatchsetOperationsImplTest.java
+++ b/javatests/com/google/gerrit/acceptance/testsuite/change/PatchsetOperationsImplTest.java
@@ -14,6 +14,7 @@
package com.google.gerrit.acceptance.testsuite.change;
+import static com.google.common.truth.Truth.assertThat;
import static com.google.gerrit.extensions.common.testing.CommentInfoSubject.assertThat;
import static com.google.gerrit.extensions.common.testing.CommentInfoSubject.assertThatList;
import static com.google.gerrit.extensions.common.testing.RobotCommentInfoSubject.assertThat;
@@ -33,6 +34,12 @@
import com.google.gerrit.extensions.restapi.RestApiException;
import com.google.gerrit.truth.NullAwareCorrespondence;
import com.google.inject.Inject;
+import java.sql.Timestamp;
+import java.time.Duration;
+import java.time.Instant;
+import java.time.LocalDateTime;
+import java.time.Month;
+import java.time.ZoneOffset;
import java.util.List;
import org.junit.Test;
@@ -315,6 +322,59 @@
}
@Test
+ public void commentIsCreatedWithSpecifiedCreationTime() throws Exception {
+ Change.Id changeId = changeOperations.newChange().create();
+
+ // Don't use nanos. NoteDb supports only second precision.
+ Instant creationTime =
+ LocalDateTime.of(2020, Month.SEPTEMBER, 15, 12, 10, 43).atZone(ZoneOffset.UTC).toInstant();
+ String commentUuid =
+ changeOperations
+ .change(changeId)
+ .currentPatchset()
+ .newComment()
+ .createdOn(creationTime)
+ .create();
+
+ Timestamp creationTimestamp = Timestamp.from(creationTime);
+ CommentInfo comment = getCommentFromServer(changeId, commentUuid);
+ assertThat(comment).updated().isEqualTo(creationTimestamp);
+ }
+
+ @Test
+ public void zoneOfCreationDateCanBeOmitted() throws Exception {
+ Change.Id changeId = changeOperations.newChange().create();
+
+ // As we don't care about the exact time zone internally used as a default, do a relative test
+ // so that we don't need to assert on exact instants in time. For a relative test, we need two
+ // comments whose creation date should be exactly the specified amount apart.
+ // Don't use nanos or millis. NoteDb supports only second precision.
+ LocalDateTime creationTime1 = LocalDateTime.of(2020, Month.SEPTEMBER, 15, 12, 10, 43);
+ LocalDateTime creationTime2 = creationTime1.plusMinutes(10);
+ String commentUuid1 =
+ changeOperations
+ .change(changeId)
+ .currentPatchset()
+ .newComment()
+ .createdOn(creationTime1)
+ .create();
+ String commentUuid2 =
+ changeOperations
+ .change(changeId)
+ .currentPatchset()
+ .newComment()
+ .createdOn(creationTime2)
+ .create();
+
+ CommentInfo comment1 = getCommentFromServer(changeId, commentUuid1);
+ Instant comment1Creation = comment1.updated.toInstant();
+ CommentInfo comment2 = getCommentFromServer(changeId, commentUuid2);
+ Instant comment2Creation = comment2.updated.toInstant();
+ Duration commentCreationDifference = Duration.between(comment1Creation, comment2Creation);
+ assertThat(commentCreationDifference).isEqualTo(Duration.ofMinutes(10));
+ }
+
+ @Test
public void draftCommentCanBeCreatedWithoutSpecifyingAnyParameters() throws Exception {
Change.Id changeId = changeOperations.newChange().create();
@@ -626,6 +686,59 @@
}
@Test
+ public void draftCommentIsCreatedWithSpecifiedCreationTime() throws Exception {
+ Change.Id changeId = changeOperations.newChange().create();
+
+ // Don't use nanos. NoteDb supports only second precision.
+ Instant creationTime =
+ LocalDateTime.of(2020, Month.SEPTEMBER, 15, 12, 10, 43).atZone(ZoneOffset.UTC).toInstant();
+ String commentUuid =
+ changeOperations
+ .change(changeId)
+ .currentPatchset()
+ .newDraftComment()
+ .createdOn(creationTime)
+ .create();
+
+ Timestamp creationTimestamp = Timestamp.from(creationTime);
+ CommentInfo comment = getDraftCommentFromServer(changeId, commentUuid);
+ assertThat(comment).updated().isEqualTo(creationTimestamp);
+ }
+
+ @Test
+ public void zoneOfCreationDateOfDraftCommentCanBeOmitted() throws Exception {
+ Change.Id changeId = changeOperations.newChange().create();
+
+ // As we don't care about the exact time zone internally used as a default, do a relative test
+ // so that we don't need to assert on exact instants in time. For a relative test, we need two
+ // comments whose creation date should be exactly the specified amount apart.
+ // Don't use nanos or millis. NoteDb supports only second precision.
+ LocalDateTime creationTime1 = LocalDateTime.of(2020, Month.SEPTEMBER, 15, 12, 10, 43);
+ LocalDateTime creationTime2 = creationTime1.plusMinutes(10);
+ String commentUuid1 =
+ changeOperations
+ .change(changeId)
+ .currentPatchset()
+ .newDraftComment()
+ .createdOn(creationTime1)
+ .create();
+ String commentUuid2 =
+ changeOperations
+ .change(changeId)
+ .currentPatchset()
+ .newDraftComment()
+ .createdOn(creationTime2)
+ .create();
+
+ CommentInfo comment1 = getDraftCommentFromServer(changeId, commentUuid1);
+ Instant comment1Creation = comment1.updated.toInstant();
+ CommentInfo comment2 = getDraftCommentFromServer(changeId, commentUuid2);
+ Instant comment2Creation = comment2.updated.toInstant();
+ Duration commentCreationDifference = Duration.between(comment1Creation, comment2Creation);
+ assertThat(commentCreationDifference).isEqualTo(Duration.ofMinutes(10));
+ }
+
+ @Test
public void noDraftCommentsAreCreatedOnCreationOfPublishedComment() throws Exception {
Change.Id changeId = changeOperations.newChange().create();
diff --git a/javatests/com/google/gerrit/elasticsearch/ElasticContainer.java b/javatests/com/google/gerrit/elasticsearch/ElasticContainer.java
index 60bf64c..1a43269 100644
--- a/javatests/com/google/gerrit/elasticsearch/ElasticContainer.java
+++ b/javatests/com/google/gerrit/elasticsearch/ElasticContainer.java
@@ -41,7 +41,7 @@
case V6_7:
return "blacktop/elasticsearch:6.7.2";
case V6_8:
- return "blacktop/elasticsearch:6.8.11";
+ return "blacktop/elasticsearch:6.8.12";
case V7_0:
return "blacktop/elasticsearch:7.0.1";
case V7_1:
diff --git a/javatests/com/google/gerrit/elasticsearch/ElasticVersionTest.java b/javatests/com/google/gerrit/elasticsearch/ElasticVersionTest.java
index 9296a6b..b9d5313 100644
--- a/javatests/com/google/gerrit/elasticsearch/ElasticVersionTest.java
+++ b/javatests/com/google/gerrit/elasticsearch/ElasticVersionTest.java
@@ -72,15 +72,15 @@
public void atLeastMinorVersion() throws Exception {
assertThat(ElasticVersion.V6_7.isAtLeastMinorVersion(ElasticVersion.V6_7)).isTrue();
assertThat(ElasticVersion.V6_8.isAtLeastMinorVersion(ElasticVersion.V6_8)).isTrue();
- assertThat(ElasticVersion.V7_0.isAtLeastMinorVersion(ElasticVersion.V6_7)).isFalse();
- assertThat(ElasticVersion.V7_1.isAtLeastMinorVersion(ElasticVersion.V6_7)).isFalse();
- assertThat(ElasticVersion.V7_2.isAtLeastMinorVersion(ElasticVersion.V6_7)).isFalse();
- assertThat(ElasticVersion.V7_3.isAtLeastMinorVersion(ElasticVersion.V6_7)).isFalse();
- assertThat(ElasticVersion.V7_4.isAtLeastMinorVersion(ElasticVersion.V6_7)).isFalse();
- assertThat(ElasticVersion.V7_5.isAtLeastMinorVersion(ElasticVersion.V6_7)).isFalse();
- assertThat(ElasticVersion.V7_6.isAtLeastMinorVersion(ElasticVersion.V6_7)).isFalse();
- assertThat(ElasticVersion.V7_7.isAtLeastMinorVersion(ElasticVersion.V6_7)).isFalse();
- assertThat(ElasticVersion.V7_8.isAtLeastMinorVersion(ElasticVersion.V6_7)).isFalse();
+ assertThat(ElasticVersion.V7_0.isAtLeastMinorVersion(ElasticVersion.V6_8)).isFalse();
+ assertThat(ElasticVersion.V7_1.isAtLeastMinorVersion(ElasticVersion.V6_8)).isFalse();
+ assertThat(ElasticVersion.V7_2.isAtLeastMinorVersion(ElasticVersion.V6_8)).isFalse();
+ assertThat(ElasticVersion.V7_3.isAtLeastMinorVersion(ElasticVersion.V6_8)).isFalse();
+ assertThat(ElasticVersion.V7_4.isAtLeastMinorVersion(ElasticVersion.V6_8)).isFalse();
+ assertThat(ElasticVersion.V7_5.isAtLeastMinorVersion(ElasticVersion.V6_8)).isFalse();
+ assertThat(ElasticVersion.V7_6.isAtLeastMinorVersion(ElasticVersion.V6_8)).isFalse();
+ assertThat(ElasticVersion.V7_7.isAtLeastMinorVersion(ElasticVersion.V6_8)).isFalse();
+ assertThat(ElasticVersion.V7_8.isAtLeastMinorVersion(ElasticVersion.V6_8)).isFalse();
}
@Test
diff --git a/javatests/com/google/gerrit/server/change/CommentThreadTest.java b/javatests/com/google/gerrit/server/change/CommentThreadTest.java
new file mode 100644
index 0000000..dc46e48
--- /dev/null
+++ b/javatests/com/google/gerrit/server/change/CommentThreadTest.java
@@ -0,0 +1,80 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.change;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Comment;
+import com.google.gerrit.entities.Comment.Key;
+import com.google.gerrit.entities.HumanComment;
+import java.sql.Timestamp;
+import org.junit.Test;
+
+public class CommentThreadTest {
+
+ @Test
+ public void threadMustContainAtLeastOneComment() {
+ assertThrows(IllegalStateException.class, () -> CommentThread.builder().build());
+ }
+
+ @Test
+ public void threadCanBeUnresolved() {
+ HumanComment root = unresolved(createComment("root"));
+ CommentThread<Comment> commentThread = CommentThread.builder().addComment(root).build();
+
+ assertThat(commentThread.unresolved()).isTrue();
+ }
+
+ @Test
+ public void threadCanBeResolved() {
+ HumanComment root = resolved(createComment("root"));
+ CommentThread<Comment> commentThread = CommentThread.builder().addComment(root).build();
+
+ assertThat(commentThread.unresolved()).isFalse();
+ }
+
+ @Test
+ public void lastCommentInThreadDeterminesUnresolvedStatus() {
+ HumanComment root = resolved(createComment("root"));
+ HumanComment child = unresolved(createComment("child"));
+ CommentThread<Comment> commentThread =
+ CommentThread.builder().addComment(root).addComment(child).build();
+
+ assertThat(commentThread.unresolved()).isTrue();
+ }
+
+ private static HumanComment createComment(String commentUuid) {
+ return new HumanComment(
+ new Key(commentUuid, "myFile", 1),
+ Account.id(100),
+ new Timestamp(1234),
+ (short) 1,
+ "Comment text",
+ "serverId",
+ true);
+ }
+
+ private static HumanComment resolved(HumanComment comment) {
+ comment.unresolved = false;
+ return comment;
+ }
+
+ private static HumanComment unresolved(HumanComment comment) {
+ comment.unresolved = true;
+ return comment;
+ }
+}
diff --git a/javatests/com/google/gerrit/server/change/CommentThreadsTest.java b/javatests/com/google/gerrit/server/change/CommentThreadsTest.java
new file mode 100644
index 0000000..56566d3
--- /dev/null
+++ b/javatests/com/google/gerrit/server/change/CommentThreadsTest.java
@@ -0,0 +1,285 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.change;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Comment.Key;
+import com.google.gerrit.entities.HumanComment;
+import java.sql.Timestamp;
+import org.junit.Test;
+
+public class CommentThreadsTest {
+
+ @Test
+ public void threadsAreEmptyWhenNoCommentsAreProvided() {
+ ImmutableList<HumanComment> comments = ImmutableList.of();
+ ImmutableSet<CommentThread<HumanComment>> commentThreads =
+ CommentThreads.forComments(comments).getThreads();
+
+ ImmutableSet<CommentThread<HumanComment>> expectedThreads = ImmutableSet.of();
+ assertThat(commentThreads).isEqualTo(expectedThreads);
+ }
+
+ @Test
+ public void threadsCanBeCreatedFromSingleRoot() {
+ HumanComment root = createComment("root");
+
+ ImmutableList<HumanComment> comments = ImmutableList.of(root);
+ ImmutableSet<CommentThread<HumanComment>> commentThreads =
+ CommentThreads.forComments(comments).getThreads();
+
+ ImmutableSet<CommentThread<HumanComment>> expectedThreads = ImmutableSet.of(toThread(root));
+ assertThat(commentThreads).isEqualTo(expectedThreads);
+ }
+
+ @Test
+ public void threadsCanBeCreatedFromUnorderedComments() {
+ HumanComment root = createComment("root");
+ HumanComment child1 = asReply(createComment("child1"), "root");
+ HumanComment child2 = asReply(createComment("child2"), "child1");
+ HumanComment child3 = asReply(createComment("child3"), "child2");
+
+ ImmutableList<HumanComment> comments = ImmutableList.of(child2, child1, root, child3);
+ ImmutableSet<CommentThread<HumanComment>> commentThreads =
+ CommentThreads.forComments(comments).getThreads();
+
+ ImmutableSet<CommentThread<HumanComment>> expectedThreads =
+ ImmutableSet.of(toThread(root, child1, child2, child3));
+ assertThat(commentThreads).isEqualTo(expectedThreads);
+ }
+
+ @Test
+ public void childWithNotAvailableParentIsAssumedToBeRoot() {
+ HumanComment child1 = asReply(createComment("child1"), "root");
+
+ ImmutableList<HumanComment> comments = ImmutableList.of(child1);
+ ImmutableSet<CommentThread<HumanComment>> commentThreads =
+ CommentThreads.forComments(comments).getThreads();
+
+ ImmutableSet<CommentThread<HumanComment>> expectedThreads = ImmutableSet.of(toThread(child1));
+ assertThat(commentThreads).isEqualTo(expectedThreads);
+ }
+
+ @Test
+ public void threadsIgnoreDuplicateRoots() {
+ HumanComment root = createComment("root");
+ HumanComment child1 = asReply(createComment("child1"), "root");
+
+ ImmutableList<HumanComment> comments = ImmutableList.of(root, root, child1);
+ ImmutableSet<CommentThread<HumanComment>> commentThreads =
+ CommentThreads.forComments(comments).getThreads();
+
+ ImmutableSet<CommentThread<HumanComment>> expectedThreads =
+ ImmutableSet.of(toThread(root, child1));
+ assertThat(commentThreads).isEqualTo(expectedThreads);
+ }
+
+ @Test
+ public void threadsIgnoreDuplicateChildren() {
+ HumanComment root = createComment("root");
+ HumanComment child1 = asReply(createComment("child1"), "root");
+
+ ImmutableList<HumanComment> comments = ImmutableList.of(root, child1, child1);
+ ImmutableSet<CommentThread<HumanComment>> commentThreads =
+ CommentThreads.forComments(comments).getThreads();
+
+ ImmutableSet<CommentThread<HumanComment>> expectedThreads =
+ ImmutableSet.of(toThread(root, child1));
+ assertThat(commentThreads).isEqualTo(expectedThreads);
+ }
+
+ @Test
+ public void commentsAreOrderedIntoCorrectThreads() {
+ HumanComment thread1Root = createComment("thread1Root");
+ HumanComment thread1Child1 = asReply(createComment("thread1Child1"), "thread1Root");
+ HumanComment thread1Child2 = asReply(createComment("thread1Child2"), "thread1Child1");
+ HumanComment thread2Root = createComment("thread2Root");
+ HumanComment thread2Child1 = asReply(createComment("thread2Child1"), "thread2Root");
+
+ ImmutableList<HumanComment> comments =
+ ImmutableList.of(thread2Root, thread1Child2, thread1Child1, thread1Root, thread2Child1);
+ ImmutableSet<CommentThread<HumanComment>> commentThreads =
+ CommentThreads.forComments(comments).getThreads();
+
+ ImmutableSet<CommentThread<HumanComment>> expectedThreads =
+ ImmutableSet.of(
+ toThread(thread1Root, thread1Child1, thread1Child2),
+ toThread(thread2Root, thread2Child1));
+ assertThat(commentThreads).isEqualTo(expectedThreads);
+ }
+
+ @Test
+ public void branchedThreadsAreFlattenedAccordingToDate() {
+ HumanComment root = writtenOn(createComment("root"), new Timestamp(1));
+ HumanComment sibling1 = writtenOn(asReply(createComment("sibling1"), "root"), new Timestamp(2));
+ HumanComment sibling2 = writtenOn(asReply(createComment("sibling2"), "root"), new Timestamp(3));
+ HumanComment sibling1Child =
+ writtenOn(asReply(createComment("sibling1Child"), "sibling1"), new Timestamp(4));
+ HumanComment sibling2Child =
+ writtenOn(asReply(createComment("sibling2Child"), "sibling2"), new Timestamp(5));
+
+ ImmutableList<HumanComment> comments =
+ ImmutableList.of(sibling2, sibling2Child, sibling1, sibling1Child, root);
+ ImmutableSet<CommentThread<HumanComment>> commentThreads =
+ CommentThreads.forComments(comments).getThreads();
+
+ ImmutableSet<CommentThread<HumanComment>> expectedThreads =
+ ImmutableSet.of(toThread(root, sibling1, sibling2, sibling1Child, sibling2Child));
+ assertThat(commentThreads).isEqualTo(expectedThreads);
+ }
+
+ @Test
+ public void threadsConsiderParentRelationshipStrongerThanDate() {
+ HumanComment root = writtenOn(createComment("root"), new Timestamp(3));
+ HumanComment child1 = writtenOn(asReply(createComment("child1"), "root"), new Timestamp(2));
+ HumanComment child2 = writtenOn(asReply(createComment("child2"), "child1"), new Timestamp(1));
+
+ ImmutableList<HumanComment> comments = ImmutableList.of(child2, child1, root);
+ ImmutableSet<CommentThread<HumanComment>> commentThreads =
+ CommentThreads.forComments(comments).getThreads();
+
+ ImmutableSet<CommentThread<HumanComment>> expectedThreads =
+ ImmutableSet.of(toThread(root, child1, child2));
+ assertThat(commentThreads).isEqualTo(expectedThreads);
+ }
+
+ @Test
+ public void threadsFallBackToUuidOrderIfParentAndDateAreTheSame() {
+ HumanComment root = writtenOn(createComment("root"), new Timestamp(1));
+ HumanComment sibling1 = writtenOn(asReply(createComment("sibling1"), "root"), new Timestamp(2));
+ HumanComment sibling2 = writtenOn(asReply(createComment("sibling2"), "root"), new Timestamp(2));
+
+ ImmutableList<HumanComment> comments = ImmutableList.of(sibling2, sibling1, root);
+ ImmutableSet<CommentThread<HumanComment>> commentThreads =
+ CommentThreads.forComments(comments).getThreads();
+
+ ImmutableSet<CommentThread<HumanComment>> expectedThreads =
+ ImmutableSet.of(toThread(root, sibling1, sibling2));
+ assertThat(commentThreads).isEqualTo(expectedThreads);
+ }
+
+ @Test
+ public void specificThreadsCanBeRequestedByTheirReply() {
+ HumanComment thread1Root = createComment("thread1Root");
+ HumanComment thread2Root = createComment("thread2Root");
+
+ HumanComment thread1Reply = asReply(createComment("thread1Reply"), "thread1Root");
+
+ ImmutableList<HumanComment> comments = ImmutableList.of(thread1Root, thread2Root, thread1Reply);
+ ImmutableSet<CommentThread<HumanComment>> commentThreads =
+ CommentThreads.forComments(comments).getThreadsForChildren(ImmutableList.of(thread1Reply));
+
+ ImmutableSet<CommentThread<HumanComment>> expectedThreads =
+ ImmutableSet.of(toThread(thread1Root, thread1Reply));
+ assertThat(commentThreads).isEqualTo(expectedThreads);
+ }
+
+ @Test
+ public void requestedThreadsDoNotNeedToContainReply() {
+ HumanComment thread1Root = createComment("thread1Root");
+ HumanComment thread2Root = createComment("thread2Root");
+
+ HumanComment thread1Reply = asReply(createComment("thread1Reply"), "thread1Root");
+
+ ImmutableList<HumanComment> comments = ImmutableList.of(thread1Root, thread2Root);
+ ImmutableSet<CommentThread<HumanComment>> commentThreads =
+ CommentThreads.forComments(comments).getThreadsForChildren(ImmutableList.of(thread1Reply));
+
+ ImmutableSet<CommentThread<HumanComment>> expectedThreads =
+ ImmutableSet.of(toThread(thread1Root));
+ assertThat(commentThreads).isEqualTo(expectedThreads);
+ }
+
+ @Test
+ public void completeThreadCanBeRequestedByReplyToRootComment() {
+ HumanComment root = createComment("root");
+ HumanComment child = asReply(createComment("child"), "root");
+
+ HumanComment reply = asReply(createComment("reply"), "root");
+
+ ImmutableList<HumanComment> comments = ImmutableList.of(root, child);
+ ImmutableSet<CommentThread<HumanComment>> commentThreads =
+ CommentThreads.forComments(comments).getThreadsForChildren(ImmutableList.of(reply));
+
+ ImmutableSet<CommentThread<HumanComment>> expectedThreads =
+ ImmutableSet.of(toThread(root, child));
+ assertThat(commentThreads).isEqualTo(expectedThreads);
+ }
+
+ @Test
+ public void completeThreadWithBranchesCanBeRequestedByReplyToIntermediateComment() {
+ HumanComment root = writtenOn(createComment("root"), new Timestamp(1));
+ HumanComment sibling1 = writtenOn(asReply(createComment("sibling1"), "root"), new Timestamp(2));
+ HumanComment sibling2 = writtenOn(asReply(createComment("sibling2"), "root"), new Timestamp(3));
+ HumanComment sibling1Child =
+ writtenOn(asReply(createComment("sibling1Child"), "sibling1"), new Timestamp(4));
+ HumanComment sibling2Child =
+ writtenOn(asReply(createComment("sibling2Child"), "sibling2"), new Timestamp(5));
+
+ HumanComment reply = asReply(createComment("sibling1"), "root");
+
+ ImmutableList<HumanComment> comments =
+ ImmutableList.of(root, sibling1, sibling2, sibling1Child, sibling2Child);
+ ImmutableSet<CommentThread<HumanComment>> commentThreads =
+ CommentThreads.forComments(comments).getThreadsForChildren(ImmutableList.of(reply));
+
+ ImmutableSet<CommentThread<HumanComment>> expectedThreads =
+ ImmutableSet.of(toThread(root, sibling1, sibling2, sibling1Child, sibling2Child));
+ assertThat(commentThreads).isEqualTo(expectedThreads);
+ }
+
+ @Test
+ public void requestedThreadsAreEmptyIfReplyDoesNotReferToAThread() {
+ HumanComment root = createComment("root");
+
+ HumanComment reply = asReply(createComment("reply"), "invalid");
+
+ ImmutableList<HumanComment> comments = ImmutableList.of(root);
+ ImmutableSet<CommentThread<HumanComment>> commentThreads =
+ CommentThreads.forComments(comments).getThreadsForChildren(ImmutableList.of(reply));
+
+ ImmutableSet<CommentThread<HumanComment>> expectedThreads = ImmutableSet.of();
+ assertThat(commentThreads).isEqualTo(expectedThreads);
+ }
+
+ private static HumanComment createComment(String commentUuid) {
+ return new HumanComment(
+ new Key(commentUuid, "myFile", 1),
+ Account.id(100),
+ new Timestamp(1234),
+ (short) 1,
+ "Comment text",
+ "serverId",
+ true);
+ }
+
+ private static HumanComment asReply(HumanComment comment, String parentUuid) {
+ comment.parentUuid = parentUuid;
+ return comment;
+ }
+
+ private static HumanComment writtenOn(HumanComment comment, Timestamp writtenOn) {
+ comment.writtenOn = writtenOn;
+ return comment;
+ }
+
+ private static CommentThread<HumanComment> toThread(HumanComment... comments) {
+ return CommentThread.<HumanComment>builder().comments(ImmutableList.copyOf(comments)).build();
+ }
+}
diff --git a/javatests/com/google/gerrit/server/restapi/change/CommentPorterTest.java b/javatests/com/google/gerrit/server/restapi/change/CommentPorterTest.java
index cb29315..9c30fc9 100644
--- a/javatests/com/google/gerrit/server/restapi/change/CommentPorterTest.java
+++ b/javatests/com/google/gerrit/server/restapi/change/CommentPorterTest.java
@@ -57,6 +57,8 @@
@Mock private PatchListCache patchListCache;
+ private int uuidCounter = 0;
+
@Test
public void commentsAreNotDroppedWhenDiffNotAvailable() throws Exception {
Project.NameKey project = Project.nameKey("myProject");
@@ -206,7 +208,7 @@
private HumanComment createComment(PatchSet.Id patchsetId, String filePath) {
return new HumanComment(
- new Comment.Key("commentUuid", filePath, patchsetId.get()),
+ new Comment.Key(getUniqueUuid(), filePath, patchsetId.get()),
Account.id(100),
new Timestamp(1234),
(short) 1,
@@ -215,6 +217,10 @@
true);
}
+ private String getUniqueUuid() {
+ return "commentUuid" + uuidCounter++;
+ }
+
private Correspondence<HumanComment, String> hasFilePath() {
return NullAwareCorrespondence.transforming(comment -> comment.key.filename, "hasFilePath");
}
diff --git a/polygerrit-ui/app/elements/admin/gr-group-audit-log/gr-group-audit-log.js b/polygerrit-ui/app/elements/admin/gr-group-audit-log/gr-group-audit-log.js
deleted file mode 100644
index 259a302..0000000
--- a/polygerrit-ui/app/elements/admin/gr-group-audit-log/gr-group-audit-log.js
+++ /dev/null
@@ -1,138 +0,0 @@
-/**
- * @license
- * Copyright (C) 2017 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-import '../../../styles/gr-table-styles.js';
-import '../../../styles/shared-styles.js';
-import '../../shared/gr-date-formatter/gr-date-formatter.js';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
-import '../../shared/gr-account-link/gr-account-link.js';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
-import {PolymerElement} from '@polymer/polymer/polymer-element.js';
-import {htmlTemplate} from './gr-group-audit-log_html.js';
-import {ListViewMixin} from '../../../mixins/gr-list-view-mixin/gr-list-view-mixin.js';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
-
-const GROUP_EVENTS = ['ADD_GROUP', 'REMOVE_GROUP'];
-
-/**
- * @extends PolymerElement
- */
-class GrGroupAuditLog extends ListViewMixin(GestureEventListeners(
- LegacyElementMixin(
- PolymerElement))) {
- static get template() { return htmlTemplate; }
-
- static get is() { return 'gr-group-audit-log'; }
-
- static get properties() {
- return {
- groupId: String,
- _auditLog: Array,
- _loading: {
- type: Boolean,
- value: true,
- },
- };
- }
-
- /** @override */
- attached() {
- super.attached();
- this.dispatchEvent(new CustomEvent('title-change', {
- detail: {title: 'Audit Log'},
- composed: true, bubbles: true,
- }));
- }
-
- /** @override */
- ready() {
- super.ready();
- this._getAuditLogs();
- }
-
- _getAuditLogs() {
- if (!this.groupId) { return ''; }
-
- const errFn = response => {
- this.dispatchEvent(new CustomEvent('page-error', {
- detail: {response},
- composed: true, bubbles: true,
- }));
- };
-
- return this.$.restAPI.getGroupAuditLog(this.groupId, errFn)
- .then(auditLog => {
- if (!auditLog) {
- this._auditLog = [];
- return;
- }
- this._auditLog = auditLog;
- this._loading = false;
- });
- }
-
- _status(item) {
- return item.disabled ? 'Disabled' : 'Enabled';
- }
-
- itemType(type) {
- let item;
- switch (type) {
- case 'ADD_GROUP':
- case 'ADD_USER':
- item = 'Added';
- break;
- case 'REMOVE_GROUP':
- case 'REMOVE_USER':
- item = 'Removed';
- break;
- default:
- item = '';
- }
- return item;
- }
-
- _isGroupEvent(type) {
- return GROUP_EVENTS.indexOf(type) !== -1;
- }
-
- _computeGroupUrl(group) {
- if (group && group.url && group.id) {
- return GerritNav.getUrlForGroup(group.id);
- }
-
- return '';
- }
-
- _getIdForUser(account) {
- return account._account_id ? ' (' + account._account_id + ')' : '';
- }
-
- _getNameForGroup(group) {
- if (group && group.name) {
- return group.name;
- } else if (group && group.id) {
- // The URL encoded id of the member
- return decodeURIComponent(group.id);
- }
-
- return '';
- }
-}
-
-customElements.define(GrGroupAuditLog.is, GrGroupAuditLog);
diff --git a/polygerrit-ui/app/elements/admin/gr-group-audit-log/gr-group-audit-log.ts b/polygerrit-ui/app/elements/admin/gr-group-audit-log/gr-group-audit-log.ts
new file mode 100644
index 0000000..f7cffac
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-group-audit-log/gr-group-audit-log.ts
@@ -0,0 +1,159 @@
+/**
+ * @license
+ * Copyright (C) 2017 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../styles/gr-table-styles';
+import '../../../styles/shared-styles';
+import '../../shared/gr-date-formatter/gr-date-formatter';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
+import '../../shared/gr-account-link/gr-account-link';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
+import {PolymerElement} from '@polymer/polymer/polymer-element';
+import {htmlTemplate} from './gr-group-audit-log_html';
+import {ListViewMixin} from '../../../mixins/gr-list-view-mixin/gr-list-view-mixin';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation';
+import {customElement, property} from '@polymer/decorators';
+import {
+ ErrorCallback,
+ RestApiService,
+} from '../../../services/services/gr-rest-api/gr-rest-api';
+import {
+ GroupInfo,
+ AccountInfo,
+ EncodedGroupId,
+ GroupAuditEventInfo,
+} from '../../../types/common';
+
+const GROUP_EVENTS = ['ADD_GROUP', 'REMOVE_GROUP'];
+
+export interface GrGroupAuditLog {
+ $: {
+ restAPI: RestApiService & Element;
+ };
+}
+@customElement('gr-group-audit-log')
+export class GrGroupAuditLog extends ListViewMixin(
+ GestureEventListeners(LegacyElementMixin(PolymerElement))
+) {
+ static get template() {
+ return htmlTemplate;
+ }
+
+ @property({type: String})
+ groupId?: EncodedGroupId;
+
+ @property({type: Array})
+ _auditLog?: GroupAuditEventInfo[];
+
+ @property({type: Boolean})
+ _loading = true;
+
+ /** @override */
+ attached() {
+ super.attached();
+ this.dispatchEvent(
+ new CustomEvent('title-change', {
+ detail: {title: 'Audit Log'},
+ composed: true,
+ bubbles: true,
+ })
+ );
+ }
+
+ /** @override */
+ ready() {
+ super.ready();
+ this._getAuditLogs();
+ }
+
+ _getAuditLogs() {
+ if (!this.groupId) {
+ return '';
+ }
+
+ const errFn: ErrorCallback = response => {
+ this.dispatchEvent(
+ new CustomEvent('page-error', {
+ detail: {response},
+ composed: true,
+ bubbles: true,
+ })
+ );
+ };
+
+ return this.$.restAPI
+ .getGroupAuditLog(this.groupId, errFn)
+ .then(auditLog => {
+ if (!auditLog) {
+ this._auditLog = [];
+ return;
+ }
+ this._auditLog = auditLog;
+ this._loading = false;
+ });
+ }
+
+ itemType(type: string) {
+ let item;
+ switch (type) {
+ case 'ADD_GROUP':
+ case 'ADD_USER':
+ item = 'Added';
+ break;
+ case 'REMOVE_GROUP':
+ case 'REMOVE_USER':
+ item = 'Removed';
+ break;
+ default:
+ item = '';
+ }
+ return item;
+ }
+
+ _isGroupEvent(type: string) {
+ return GROUP_EVENTS.indexOf(type) !== -1;
+ }
+
+ _computeGroupUrl(group: GroupInfo) {
+ if (group && group.url && group.id) {
+ return GerritNav.getUrlForGroup(group.id);
+ }
+
+ return '';
+ }
+
+ _getIdForUser(account: AccountInfo) {
+ return account._account_id ? ` (${account._account_id})` : '';
+ }
+
+ _getNameForGroup(group: GroupInfo) {
+ if (group && group.name) {
+ return group.name;
+ } else if (group && group.id) {
+ // The URL encoded id of the member
+ return decodeURIComponent(group.id);
+ }
+
+ return '';
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'gr-group-audit-log': GrGroupAuditLog;
+ }
+}
diff --git a/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members.js b/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members.js
deleted file mode 100644
index 0fb57d3..0000000
--- a/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members.js
+++ /dev/null
@@ -1,310 +0,0 @@
-/**
- * @license
- * Copyright (C) 2017 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import '@polymer/iron-autogrow-textarea/iron-autogrow-textarea.js';
-import '../../../styles/gr-form-styles.js';
-import '../../../styles/gr-subpage-styles.js';
-import '../../../styles/gr-table-styles.js';
-import '../../../styles/shared-styles.js';
-import '../../shared/gr-account-link/gr-account-link.js';
-import '../../shared/gr-autocomplete/gr-autocomplete.js';
-import '../../shared/gr-button/gr-button.js';
-import '../../shared/gr-overlay/gr-overlay.js';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
-import '../gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog.js';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
-import {PolymerElement} from '@polymer/polymer/polymer-element.js';
-import {htmlTemplate} from './gr-group-members_html.js';
-import {getBaseUrl} from '../../../utils/url-util.js';
-
-const SUGGESTIONS_LIMIT = 15;
-const SAVING_ERROR_TEXT = 'Group may not exist, or you may not have '+
- 'permission to add it';
-
-const URL_REGEX = '^(?:[a-z]+:)?//';
-
-/**
- * @extends PolymerElement
- */
-class GrGroupMembers extends GestureEventListeners(
- LegacyElementMixin(
- PolymerElement)) {
- static get template() { return htmlTemplate; }
-
- static get is() { return 'gr-group-members'; }
-
- static get properties() {
- return {
- groupId: Number,
- _groupMemberSearchId: String,
- _groupMemberSearchName: String,
- _includedGroupSearchId: String,
- _includedGroupSearchName: String,
- _loading: {
- type: Boolean,
- value: true,
- },
- _groupName: String,
- _groupMembers: Object,
- _includedGroups: Object,
- _itemName: String,
- _itemType: String,
- _queryMembers: {
- type: Function,
- value() {
- return input => this._getAccountSuggestions(input);
- },
- },
- _queryIncludedGroup: {
- type: Function,
- value() {
- return input => this._getGroupSuggestions(input);
- },
- },
- _groupOwner: {
- type: Boolean,
- value: false,
- },
- _isAdmin: {
- type: Boolean,
- value: false,
- },
- };
- }
-
- /** @override */
- attached() {
- super.attached();
- this._loadGroupDetails();
-
- this.dispatchEvent(new CustomEvent('title-change', {
- detail: {title: 'Members'},
- composed: true, bubbles: true,
- }));
- }
-
- _loadGroupDetails() {
- if (!this.groupId) { return; }
-
- const promises = [];
-
- const errFn = response => {
- this.dispatchEvent(new CustomEvent('page-error', {
- detail: {response},
- composed: true, bubbles: true,
- }));
- };
-
- return this.$.restAPI.getGroupConfig(this.groupId, errFn)
- .then(config => {
- if (!config || !config.name) { return Promise.resolve(); }
-
- this._groupName = config.name;
-
- promises.push(this.$.restAPI.getIsAdmin().then(isAdmin => {
- this._isAdmin = !!isAdmin;
- }));
-
- promises.push(this.$.restAPI.getIsGroupOwner(config.name)
- .then(isOwner => {
- this._groupOwner = !!isOwner;
- }));
-
- promises.push(this.$.restAPI.getGroupMembers(config.name).then(
- members => {
- this._groupMembers = members;
- }));
-
- promises.push(this.$.restAPI.getIncludedGroup(config.name)
- .then(includedGroup => {
- this._includedGroups = includedGroup;
- }));
-
- return Promise.all(promises).then(() => {
- this._loading = false;
- });
- });
- }
-
- _computeLoadingClass(loading) {
- return loading ? 'loading' : '';
- }
-
- _isLoading() {
- return this._loading || this._loading === undefined;
- }
-
- _computeGroupUrl(url) {
- if (!url) { return; }
-
- const r = new RegExp(URL_REGEX, 'i');
- if (r.test(url)) {
- return url;
- }
-
- // For GWT compatibility
- if (url.startsWith('#')) {
- return getBaseUrl() + url.slice(1);
- }
- return getBaseUrl() + url;
- }
-
- _handleSavingGroupMember() {
- return this.$.restAPI.saveGroupMember(this._groupName,
- this._groupMemberSearchId).then(config => {
- if (!config) {
- return;
- }
- this.$.restAPI.getGroupMembers(this._groupName).then(members => {
- this._groupMembers = members;
- });
- this._groupMemberSearchName = '';
- this._groupMemberSearchId = '';
- });
- }
-
- _handleDeleteConfirm() {
- this.$.overlay.close();
- if (this._itemType === 'member') {
- return this.$.restAPI.deleteGroupMember(this._groupName,
- this._itemId)
- .then(itemDeleted => {
- if (itemDeleted.status === 204) {
- this.$.restAPI.getGroupMembers(this._groupName)
- .then(members => {
- this._groupMembers = members;
- });
- }
- });
- } else if (this._itemType === 'includedGroup') {
- return this.$.restAPI.deleteIncludedGroup(this._groupName,
- this._itemId)
- .then(itemDeleted => {
- if (itemDeleted.status === 204 || itemDeleted.status === 205) {
- this.$.restAPI.getIncludedGroup(this._groupName)
- .then(includedGroup => {
- this._includedGroups = includedGroup;
- });
- }
- });
- }
- }
-
- _handleConfirmDialogCancel() {
- this.$.overlay.close();
- }
-
- _handleDeleteMember(e) {
- const id = e.model.get('item._account_id');
- const name = e.model.get('item.name');
- const username = e.model.get('item.username');
- const email = e.model.get('item.email');
- const item = username || name || email || id;
- if (!item) {
- return '';
- }
- this._itemName = item;
- this._itemId = id;
- this._itemType = 'member';
- this.$.overlay.open();
- }
-
- _handleSavingIncludedGroups() {
- return this.$.restAPI.saveIncludedGroup(this._groupName,
- this._includedGroupSearchId.replace(/\+/g, ' '), (errResponse, err) => {
- if (errResponse) {
- if (errResponse.status === 404) {
- this.dispatchEvent(new CustomEvent('show-alert', {
- detail: {message: SAVING_ERROR_TEXT},
- bubbles: true,
- composed: true,
- }));
- return errResponse;
- }
- throw Error(err.statusText);
- }
- throw err;
- })
- .then(config => {
- if (!config) {
- return;
- }
- this.$.restAPI.getIncludedGroup(this._groupName)
- .then(includedGroup => {
- this._includedGroups = includedGroup;
- });
- this._includedGroupSearchName = '';
- this._includedGroupSearchId = '';
- });
- }
-
- _handleDeleteIncludedGroup(e) {
- const id = decodeURIComponent(e.model.get('item.id')).replace(/\+/g, ' ');
- const name = e.model.get('item.name');
- const item = name || id;
- if (!item) { return ''; }
- this._itemName = item;
- this._itemId = id;
- this._itemType = 'includedGroup';
- this.$.overlay.open();
- }
-
- _getAccountSuggestions(input) {
- if (input.length === 0) { return Promise.resolve([]); }
- return this.$.restAPI.getSuggestedAccounts(
- input, SUGGESTIONS_LIMIT).then(accounts => {
- const accountSuggestions = [];
- let nameAndEmail;
- if (!accounts) { return []; }
- for (const key in accounts) {
- if (!accounts.hasOwnProperty(key)) { continue; }
- if (accounts[key].email !== undefined) {
- nameAndEmail = accounts[key].name +
- ' <' + accounts[key].email + '>';
- } else {
- nameAndEmail = accounts[key].name;
- }
- accountSuggestions.push({
- name: nameAndEmail,
- value: accounts[key]._account_id,
- });
- }
- return accountSuggestions;
- });
- }
-
- _getGroupSuggestions(input) {
- return this.$.restAPI.getSuggestedGroups(input)
- .then(response => {
- const groups = [];
- for (const key in response) {
- if (!response.hasOwnProperty(key)) { continue; }
- groups.push({
- name: key,
- value: decodeURIComponent(response[key].id),
- });
- }
- return groups;
- });
- }
-
- _computeHideItemClass(owner, admin) {
- return admin || owner ? '' : 'canModify';
- }
-}
-
-customElements.define(GrGroupMembers.is, GrGroupMembers);
diff --git a/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members.ts b/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members.ts
new file mode 100644
index 0000000..f8f1fee
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members.ts
@@ -0,0 +1,396 @@
+/**
+ * @license
+ * Copyright (C) 2017 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import '@polymer/iron-autogrow-textarea/iron-autogrow-textarea';
+import '../../../styles/gr-form-styles';
+import '../../../styles/gr-subpage-styles';
+import '../../../styles/gr-table-styles';
+import '../../../styles/shared-styles';
+import '../../shared/gr-account-link/gr-account-link';
+import '../../shared/gr-autocomplete/gr-autocomplete';
+import '../../shared/gr-button/gr-button';
+import '../../shared/gr-overlay/gr-overlay';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
+import '../gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
+import {PolymerElement} from '@polymer/polymer/polymer-element';
+import {htmlTemplate} from './gr-group-members_html';
+import {getBaseUrl} from '../../../utils/url-util';
+import {customElement, property} from '@polymer/decorators';
+import {
+ RestApiService,
+ ErrorCallback,
+} from '../../../services/services/gr-rest-api/gr-rest-api';
+import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
+import {
+ GroupId,
+ AccountId,
+ AccountInfo,
+ GroupInfo,
+} from '../../../types/common';
+import {AutocompleteQuery} from '../../shared/gr-autocomplete/gr-autocomplete';
+import {PolymerDomRepeatEvent} from '../../../types/types';
+import {hasOwnProperty} from '../../../utils/common-util';
+
+const SUGGESTIONS_LIMIT = 15;
+const SAVING_ERROR_TEXT =
+ 'Group may not exist, or you may not have ' + 'permission to add it';
+
+const URL_REGEX = '^(?:[a-z]+:)?//';
+
+export interface GrGroupMembers {
+ $: {
+ restAPI: RestApiService & Element;
+ overlay: GrOverlay;
+ };
+}
+@customElement('gr-group-members')
+export class GrGroupMembers extends GestureEventListeners(
+ LegacyElementMixin(PolymerElement)
+) {
+ static get template() {
+ return htmlTemplate;
+ }
+
+ @property({type: Number})
+ groupId?: GroupId;
+
+ @property({type: Number})
+ _groupMemberSearchId?: number;
+
+ @property({type: String})
+ _groupMemberSearchName?: string;
+
+ @property({type: String})
+ _includedGroupSearchId?: string;
+
+ @property({type: String})
+ _includedGroupSearchName?: string;
+
+ @property({type: Boolean})
+ _loading = true;
+
+ @property({type: String})
+ _groupName?: GroupId;
+
+ @property({type: Object})
+ _groupMembers?: AccountInfo[];
+
+ @property({type: Object})
+ _includedGroups?: GroupInfo[];
+
+ @property({type: String})
+ _itemName?: GroupInfo | AccountInfo;
+
+ @property({type: String})
+ _itemType?: string;
+
+ @property({type: Object})
+ _queryMembers: AutocompleteQuery;
+
+ @property({type: Object})
+ _queryIncludedGroup: AutocompleteQuery;
+
+ @property({type: Boolean})
+ _groupOwner = false;
+
+ @property({type: Boolean})
+ _isAdmin = false;
+
+ _itemId?: AccountId | GroupId;
+
+ constructor() {
+ super();
+ this._queryMembers = input => this._getAccountSuggestions(input);
+ this._queryIncludedGroup = input => this._getGroupSuggestions(input);
+ }
+
+ /** @override */
+ attached() {
+ super.attached();
+ this._loadGroupDetails();
+
+ this.dispatchEvent(
+ new CustomEvent('title-change', {
+ detail: {title: 'Members'},
+ composed: true,
+ bubbles: true,
+ })
+ );
+ }
+
+ _loadGroupDetails() {
+ if (!this.groupId) {
+ return;
+ }
+
+ const promises: Promise<void>[] = [];
+
+ const errFn: ErrorCallback = response => {
+ this.dispatchEvent(
+ new CustomEvent('page-error', {
+ detail: {response},
+ composed: true,
+ bubbles: true,
+ })
+ );
+ };
+
+ return this.$.restAPI.getGroupConfig(this.groupId, errFn).then(config => {
+ if (!config || !config.name) {
+ return Promise.resolve();
+ }
+
+ this._groupName = config.name as GroupId;
+
+ promises.push(
+ this.$.restAPI.getIsAdmin().then(isAdmin => {
+ this._isAdmin = !!isAdmin;
+ })
+ );
+
+ promises.push(
+ this.$.restAPI.getIsGroupOwner(this._groupName).then(isOwner => {
+ this._groupOwner = !!isOwner;
+ })
+ );
+
+ promises.push(
+ this.$.restAPI.getGroupMembers(this._groupName).then(members => {
+ this._groupMembers = members;
+ })
+ );
+
+ promises.push(
+ this.$.restAPI.getIncludedGroup(this._groupName).then(includedGroup => {
+ this._includedGroups = includedGroup;
+ })
+ );
+
+ return Promise.all(promises).then(() => {
+ this._loading = false;
+ });
+ });
+ }
+
+ _computeLoadingClass(loading: boolean) {
+ return loading ? 'loading' : '';
+ }
+
+ _isLoading() {
+ return this._loading || this._loading === undefined;
+ }
+
+ _computeGroupUrl(url: string) {
+ if (!url) {
+ return;
+ }
+
+ const r = new RegExp(URL_REGEX, 'i');
+ if (r.test(url)) {
+ return url;
+ }
+
+ // For GWT compatibility
+ if (url.startsWith('#')) {
+ return getBaseUrl() + url.slice(1);
+ }
+ return getBaseUrl() + url;
+ }
+
+ _handleSavingGroupMember() {
+ if (!this._groupName) {
+ return Promise.reject(new Error('group name undefined'));
+ }
+ return this.$.restAPI
+ .saveGroupMember(this._groupName, this._groupMemberSearchId as AccountId)
+ .then(config => {
+ if (!config || !this._groupName) {
+ return;
+ }
+ this.$.restAPI.getGroupMembers(this._groupName).then(members => {
+ this._groupMembers = members;
+ });
+ this._groupMemberSearchName = '';
+ this._groupMemberSearchId = undefined;
+ });
+ }
+
+ _handleDeleteConfirm() {
+ if (!this._groupName) {
+ return Promise.reject(new Error('group name undefined'));
+ }
+ this.$.overlay.close();
+ if (this._itemType === 'member') {
+ return this.$.restAPI
+ .deleteGroupMember(this._groupName, this._itemId! as AccountId)
+ .then(itemDeleted => {
+ if (itemDeleted.status === 204 && this._groupName) {
+ this.$.restAPI.getGroupMembers(this._groupName).then(members => {
+ this._groupMembers = members;
+ });
+ }
+ });
+ } else if (this._itemType === 'includedGroup') {
+ return this.$.restAPI
+ .deleteIncludedGroup(this._groupName, this._itemId! as GroupId)
+ .then(itemDeleted => {
+ if (
+ (itemDeleted.status === 204 || itemDeleted.status === 205) &&
+ this._groupName
+ ) {
+ this.$.restAPI
+ .getIncludedGroup(this._groupName)
+ .then(includedGroup => {
+ this._includedGroups = includedGroup;
+ });
+ }
+ });
+ }
+ return Promise.reject(new Error('Unrecognized item type'));
+ }
+
+ _handleConfirmDialogCancel() {
+ this.$.overlay.close();
+ }
+
+ _handleDeleteMember(e: PolymerDomRepeatEvent<AccountInfo>) {
+ const id = (e.model.get('item._account_id') as unknown) as AccountId;
+ const name = e.model.get('item.name');
+ const username = e.model.get('item.username');
+ const email = e.model.get('item.email');
+ const item = username || name || email || id;
+ if (!item) {
+ return;
+ }
+ this._itemName = item;
+ this._itemId = id;
+ this._itemType = 'member';
+ this.$.overlay.open();
+ }
+
+ _handleSavingIncludedGroups() {
+ if (!this._groupName || !this._includedGroupSearchId) {
+ return Promise.reject(
+ new Error('group name or includedGroupSearchId undefined')
+ );
+ }
+ return this.$.restAPI
+ .saveIncludedGroup(
+ this._groupName,
+ this._includedGroupSearchId.replace(/\+/g, ' ') as GroupId,
+ (errResponse, err) => {
+ if (errResponse) {
+ if (errResponse.status === 404) {
+ this.dispatchEvent(
+ new CustomEvent('show-alert', {
+ detail: {message: SAVING_ERROR_TEXT},
+ bubbles: true,
+ composed: true,
+ })
+ );
+ return errResponse;
+ }
+ throw Error(errResponse.statusText);
+ }
+ throw err;
+ }
+ )
+ .then(config => {
+ if (!config || !this._groupName) {
+ return;
+ }
+ this.$.restAPI.getIncludedGroup(this._groupName).then(includedGroup => {
+ this._includedGroups = includedGroup;
+ });
+ this._includedGroupSearchName = '';
+ this._includedGroupSearchId = '';
+ });
+ }
+
+ _handleDeleteIncludedGroup(e: PolymerDomRepeatEvent<GroupInfo>) {
+ const id = decodeURIComponent(`${e.model.get('item.id')}`).replace(
+ /\+/g,
+ ' '
+ ) as GroupId;
+ const name = e.model.get('item.name');
+ const item = name || id;
+ if (!item) {
+ return;
+ }
+ this._itemName = item;
+ this._itemId = id;
+ this._itemType = 'includedGroup';
+ this.$.overlay.open();
+ }
+
+ _getAccountSuggestions(input: string) {
+ if (input.length === 0) {
+ return Promise.resolve([]);
+ }
+ return this.$.restAPI
+ .getSuggestedAccounts(input, SUGGESTIONS_LIMIT)
+ .then(accounts => {
+ const accountSuggestions = [];
+ let nameAndEmail;
+ if (!accounts) {
+ return [];
+ }
+ for (const key in accounts) {
+ if (!hasOwnProperty(accounts, key)) {
+ continue;
+ }
+ if (accounts[key].email !== undefined) {
+ nameAndEmail = `${accounts[key].name} <${accounts[key].email}>`;
+ } else {
+ nameAndEmail = accounts[key].name;
+ }
+ accountSuggestions.push({
+ name: nameAndEmail,
+ value: accounts[key]._account_id,
+ });
+ }
+ return accountSuggestions;
+ });
+ }
+
+ _getGroupSuggestions(input: string) {
+ return this.$.restAPI.getSuggestedGroups(input).then(response => {
+ const groups = [];
+ for (const key in response) {
+ if (!hasOwnProperty(response, key)) {
+ continue;
+ }
+ groups.push({
+ name: key,
+ value: decodeURIComponent(response[key].id),
+ });
+ }
+ return groups;
+ });
+ }
+
+ _computeHideItemClass(owner: boolean, admin: boolean) {
+ return admin || owner ? '' : 'canModify';
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'gr-group-members': GrGroupMembers;
+ }
+}
diff --git a/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members_test.js b/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members_test.js
index 03fccde..b5d2217 100644
--- a/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members_test.js
+++ b/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members_test.js
@@ -17,7 +17,7 @@
import '../../../test/common-test-setup-karma.js';
import './gr-group-members.js';
-import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+import {dom, flush} from '@polymer/polymer/lib/legacy/polymer.dom.js';
import {stubBaseUrl} from '../../../test/test-utils.js';
const basicFixture = fixtureFromElement('gr-group-members');
@@ -210,6 +210,7 @@
test('add included group 404 shows helpful error text', () => {
element._groupOwner = true;
+ element._groupName = 'test';
const memberName = 'bad-name';
const alertStub = sinon.stub();
@@ -224,9 +225,9 @@
element.$.groupMemberSearchInput.text = memberName;
element.$.groupMemberSearchInput.value = 1234;
- return element._handleSavingIncludedGroups().then(() => {
+ return flush(element._handleSavingIncludedGroups().then(() => {
assert.isTrue(alertStub.called);
- });
+ }));
});
test('add included group network-error throws an exception', async () => {
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item.js b/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item.js
deleted file mode 100644
index 673332b..0000000
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item.js
+++ /dev/null
@@ -1,321 +0,0 @@
-/**
- * @license
- * Copyright (C) 2015 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-import '../../../styles/gr-change-list-styles.js';
-import '../../shared/gr-account-link/gr-account-link.js';
-import '../../shared/gr-change-star/gr-change-star.js';
-import '../../shared/gr-change-status/gr-change-status.js';
-import '../../shared/gr-date-formatter/gr-date-formatter.js';
-import '../../shared/gr-icons/gr-icons.js';
-import '../../shared/gr-limited-text/gr-limited-text.js';
-import '../../shared/gr-tooltip-content/gr-tooltip-content.js';
-import '../../../styles/shared-styles.js';
-import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator.js';
-import '../../plugins/gr-endpoint-param/gr-endpoint-param.js';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
-import {PolymerElement} from '@polymer/polymer/polymer-element.js';
-import {htmlTemplate} from './gr-change-list-item_html.js';
-import {ChangeTableMixin} from '../../../mixins/gr-change-table-mixin/gr-change-table-mixin.js';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
-import {getDisplayName} from '../../../utils/display-name-util.js';
-import {getPluginEndpoints} from '../../shared/gr-js-api-interface/gr-plugin-endpoints.js';
-import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader.js';
-import {appContext} from '../../../services/app-context.js';
-import {truncatePath} from '../../../utils/path-list-util.js';
-import {changeStatuses} from '../../../utils/change-util.js';
-import {isServiceUser} from '../../../utils/account-util.js';
-
-const CHANGE_SIZE = {
- XS: 10,
- SMALL: 50,
- MEDIUM: 250,
- LARGE: 1000,
-};
-
-// How many reviewers should be shown with an account-label?
-const PRIMARY_REVIEWERS_COUNT = 2;
-
-/**
- * @extends PolymerElement
- */
-class GrChangeListItem extends ChangeTableMixin(GestureEventListeners(
- LegacyElementMixin(PolymerElement))) {
- static get template() { return htmlTemplate; }
-
- static get is() { return 'gr-change-list-item'; }
-
- static get properties() {
- return {
- /** The logged-in user's account, or null if no user is logged in. */
- account: {
- type: Object,
- value: null,
- },
- visibleChangeTableColumns: Array,
- labelNames: {
- type: Array,
- },
-
- /** @type {?} */
- change: Object,
- config: Object,
- /** Name of the section in the change-list. Used for reporting. */
- sectionName: String,
- changeURL: {
- type: String,
- computed: '_computeChangeURL(change)',
- },
- statuses: {
- type: Array,
- computed: '_changeStatuses(change)',
- },
- showStar: {
- type: Boolean,
- value: false,
- },
- showNumber: Boolean,
- _changeSize: {
- type: String,
- computed: '_computeChangeSize(change)',
- },
- _dynamicCellEndpoints: {
- type: Array,
- },
- };
- }
-
- constructor() {
- super();
- this.reporting = appContext.reportingService;
- }
-
- /** @override */
- attached() {
- super.attached();
- getPluginLoader().awaitPluginsLoaded()
- .then(() => {
- this._dynamicCellEndpoints = getPluginEndpoints().getDynamicEndpoints(
- 'change-list-item-cell');
- });
- }
-
- _changeStatuses(change) {
- return changeStatuses(change);
- }
-
- _computeChangeURL(change) {
- return GerritNav.getUrlForChange(change);
- }
-
- _computeLabelTitle(change, labelName) {
- const label = change.labels[labelName];
- if (!label) { return 'Label not applicable'; }
- const significantLabel = label.rejected || label.approved ||
- label.disliked || label.recommended;
- if (significantLabel && significantLabel.name) {
- return labelName + '\nby ' + significantLabel.name;
- }
- return labelName;
- }
-
- _computeLabelClass(change, labelName) {
- const label = change.labels[labelName];
- // Mimic a Set.
- const classes = {
- cell: true,
- label: true,
- };
- if (label) {
- if (label.approved) {
- classes['u-green'] = true;
- }
- if (label.value == 1) {
- classes['u-monospace'] = true;
- classes['u-green'] = true;
- } else if (label.value == -1) {
- classes['u-monospace'] = true;
- classes['u-red'] = true;
- }
- if (label.rejected) {
- classes['u-red'] = true;
- }
- } else {
- classes['u-gray-background'] = true;
- }
- return Object.keys(classes).sort()
- .join(' ');
- }
-
- _computeLabelValue(change, labelName) {
- const label = change.labels[labelName];
- if (!label) { return ''; }
- if (label.approved) {
- return '✓';
- }
- if (label.rejected) {
- return '✕';
- }
- if (label.value > 0) {
- return '+' + label.value;
- }
- if (label.value < 0) {
- return label.value;
- }
- return '';
- }
-
- _computeRepoUrl(change) {
- return GerritNav.getUrlForProjectChanges(change.project, true,
- change.internalHost);
- }
-
- _computeRepoBranchURL(change) {
- return GerritNav.getUrlForBranch(change.branch, change.project, null,
- change.internalHost);
- }
-
- _computeTopicURL(change) {
- if (!change.topic) { return ''; }
- return GerritNav.getUrlForTopic(change.topic, 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 {!Object} change
- * @param {string=} truncate whether or not the project name should be
- * truncated. If this value is truthy, the name will be truncated.
- * @return {string}
- */
- _computeRepoDisplay(change, truncate) {
- if (!change || !change.project) { return ''; }
- let str = '';
- if (change.internalHost) { str += change.internalHost + '/'; }
- str += truncate ? truncatePath(change.project, 2) : change.project;
- return str;
- }
-
- _computeSizeTooltip(change) {
- if (change.insertions + change.deletions === 0 ||
- isNaN(change.insertions + change.deletions)) {
- return 'Size unknown';
- } else {
- return `added ${change.insertions}, removed ${change.deletions} lines`;
- }
- }
-
- _hasAttention(account) {
- if (!this.change || !this.change.attention_set) return false;
- return this.change.attention_set.hasOwnProperty(account._account_id);
- }
-
- /**
- * Computes the array of all reviewers with sorting the reviewers in the
- * attention set before others, and the current user first.
- */
- _computeReviewers(change) {
- if (!change || !change.reviewers || !change.reviewers.REVIEWER) return [];
- const reviewers = [...change.reviewers.REVIEWER].filter(r =>
- (!change.owner || change.owner._account_id !== r._account_id) &&
- !isServiceUser(r)
- );
- reviewers.sort((r1, r2) => {
- if (this.account) {
- if (r1._account_id === this.account._account_id) return -1;
- if (r2._account_id === this.account._account_id) return 1;
- }
- if (this._hasAttention(r1) && !this._hasAttention(r2)) return -1;
- if (this._hasAttention(r2) && !this._hasAttention(r1)) return 1;
- return (r1.name || '').localeCompare(r2.name || '');
- });
- return reviewers;
- }
-
- _computePrimaryReviewers(change) {
- return this._computeReviewers(change).slice(0, PRIMARY_REVIEWERS_COUNT);
- }
-
- _computeAdditionalReviewers(change) {
- return this._computeReviewers(change).slice(PRIMARY_REVIEWERS_COUNT);
- }
-
- _computeAdditionalReviewersCount(change) {
- return this._computeAdditionalReviewers(change).length;
- }
-
- _computeAdditionalReviewersTitle(change, config) {
- if (!change || !config) return '';
- return this._computeAdditionalReviewers(change)
- .map(user => getDisplayName(config, user, true))
- .join(', ');
- }
-
- _computeComments(unresolved_comment_count) {
- if (!unresolved_comment_count || unresolved_comment_count < 1) return '';
- return `${unresolved_comment_count} unresolved`;
- }
-
- /**
- * TShirt sizing is based on the following paper:
- * http://dirkriehle.com/wp-content/uploads/2008/09/hicss-42-csdistr-final-web.pdf
- */
- _computeChangeSize(change) {
- const delta = change.insertions + change.deletions;
- if (isNaN(delta) || delta === 0) {
- return null; // Unknown
- }
- if (delta < CHANGE_SIZE.XS) {
- return 'XS';
- } else if (delta < CHANGE_SIZE.SMALL) {
- return 'S';
- } else if (delta < CHANGE_SIZE.MEDIUM) {
- return 'M';
- } else if (delta < CHANGE_SIZE.LARGE) {
- return 'L';
- } else {
- return 'XL';
- }
- }
-
- toggleReviewed() {
- const newVal = !this.change.reviewed;
- this.set('change.reviewed', newVal);
- this.dispatchEvent(new CustomEvent('toggle-reviewed', {
- bubbles: true,
- composed: true,
- detail: {change: this.change, reviewed: newVal},
- }));
- }
-
- _handleChangeClick(e) {
- // Don't prevent the default and neither stop bubbling. We just want to
- // report the click, but then let the browser handle the click on the link.
-
- const selfId = (this.account && this.account._account_id) || -1;
- const ownerId = (this.change && this.change.owner
- && this.change.owner._account_id) || -1;
-
- this.reporting.reportInteraction('change-row-clicked', {
- section: this.sectionName,
- isOwner: selfId === ownerId,
- });
- }
-}
-
-customElements.define(GrChangeListItem.is, GrChangeListItem);
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
new file mode 100644
index 0000000..5d898bd
--- /dev/null
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item.ts
@@ -0,0 +1,366 @@
+/**
+ * @license
+ * Copyright (C) 2015 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../styles/gr-change-list-styles';
+import '../../shared/gr-account-link/gr-account-link';
+import '../../shared/gr-change-star/gr-change-star';
+import '../../shared/gr-change-status/gr-change-status';
+import '../../shared/gr-date-formatter/gr-date-formatter';
+import '../../shared/gr-icons/gr-icons';
+import '../../shared/gr-limited-text/gr-limited-text';
+import '../../shared/gr-tooltip-content/gr-tooltip-content';
+import '../../../styles/shared-styles';
+import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator';
+import '../../plugins/gr-endpoint-param/gr-endpoint-param';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
+import {PolymerElement} from '@polymer/polymer/polymer-element';
+import {htmlTemplate} from './gr-change-list-item_html';
+import {ChangeTableMixin} from '../../../mixins/gr-change-table-mixin/gr-change-table-mixin';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation';
+import {getDisplayName} from '../../../utils/display-name-util';
+import {getPluginEndpoints} from '../../shared/gr-js-api-interface/gr-plugin-endpoints';
+import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader';
+import {appContext} from '../../../services/app-context';
+import {truncatePath} from '../../../utils/path-list-util';
+import {changeStatuses} from '../../../utils/change-util';
+import {isServiceUser} from '../../../utils/account-util';
+import {customElement, property} from '@polymer/decorators';
+import {ReportingService} from '../../../services/gr-reporting/gr-reporting';
+import {
+ ChangeInfo,
+ ServerInfo,
+ AccountInfo,
+ QuickLabelInfo,
+} from '../../../types/common';
+import {hasOwnProperty} from '../../../utils/common-util';
+
+enum CHANGE_SIZE {
+ XS = 10,
+ SMALL = 50,
+ MEDIUM = 250,
+ LARGE = 1000,
+}
+
+// How many reviewers should be shown with an account-label?
+const PRIMARY_REVIEWERS_COUNT = 2;
+
+@customElement('gr-change-list-item')
+class GrChangeListItem extends ChangeTableMixin(
+ GestureEventListeners(LegacyElementMixin(PolymerElement))
+) {
+ static get template() {
+ return htmlTemplate;
+ }
+
+ /** The logged-in user's account, or null if no user is logged in. */
+ @property({type: Object})
+ account: AccountInfo | null = null;
+
+ @property({type: Array})
+ visibleChangeTableColumns?: string[];
+
+ @property({type: Array})
+ labelNames?: string[];
+
+ @property({type: Object})
+ change?: ChangeInfo;
+
+ @property({type: Object})
+ config?: ServerInfo;
+
+ /** Name of the section in the change-list. Used for reporting. */
+ @property({type: String})
+ sectionName?: string;
+
+ @property({type: String, computed: '_computeChangeURL(change)'})
+ changeURL?: string;
+
+ @property({type: Array, computed: '_changeStatuses(change)'})
+ statuses?: string[];
+
+ @property({type: Boolean})
+ showStar = false;
+
+ @property({type: Boolean})
+ showNumber = false;
+
+ @property({type: String, computed: '_computeChangeSize(change)'})
+ _changeSize?: string;
+
+ @property({type: Array})
+ _dynamicCellEndpoints?: string[];
+
+ reporting: ReportingService = appContext.reportingService;
+
+ /** @override */
+ attached() {
+ super.attached();
+ getPluginLoader()
+ .awaitPluginsLoaded()
+ .then(() => {
+ this._dynamicCellEndpoints = getPluginEndpoints().getDynamicEndpoints(
+ 'change-list-item-cell'
+ );
+ });
+ }
+
+ _changeStatuses(change?: ChangeInfo) {
+ if (!change) return [];
+ return changeStatuses(change);
+ }
+
+ _computeChangeURL(change?: ChangeInfo) {
+ if (!change) return '';
+ return GerritNav.getUrlForChange(change);
+ }
+
+ _computeLabelTitle(change: ChangeInfo | undefined, labelName: string) {
+ const label: QuickLabelInfo | undefined = change?.labels?.[labelName];
+ if (!label) {
+ return 'Label not applicable';
+ }
+ const significantLabel =
+ label.rejected || label.approved || label.disliked || label.recommended;
+ if (significantLabel && significantLabel.name) {
+ return `${labelName}\nby ${significantLabel.name}`;
+ }
+ return labelName;
+ }
+
+ _computeLabelClass(change: ChangeInfo | undefined, labelName: string) {
+ const label: QuickLabelInfo | undefined = change?.labels?.[labelName];
+ // Mimic a Set.
+ // TODO(TS): replace with `u_green` to remove the quotes and brackets
+ const classes: {
+ cell: boolean;
+ label: boolean;
+ ['u-green']?: boolean;
+ ['u-monospace']?: boolean;
+ ['u-red']?: boolean;
+ ['u-gray-background']?: boolean;
+ } = {
+ cell: true,
+ label: true,
+ };
+ if (label) {
+ if (label.approved) {
+ classes['u-green'] = true;
+ }
+ if (label.value === 1) {
+ classes['u-monospace'] = true;
+ classes['u-green'] = true;
+ } else if (label.value === -1) {
+ classes['u-monospace'] = true;
+ classes['u-red'] = true;
+ }
+ if (label.rejected) {
+ classes['u-red'] = true;
+ }
+ } else {
+ classes['u-gray-background'] = true;
+ }
+ return Object.keys(classes).sort().join(' ');
+ }
+
+ _computeLabelValue(change: ChangeInfo | undefined, labelName: string) {
+ const label: QuickLabelInfo | undefined = change?.labels?.[labelName];
+ if (!label) {
+ return '';
+ }
+ if (label.approved) {
+ return '✓';
+ }
+ if (label.rejected) {
+ return '✕';
+ }
+ if (label.value && label.value > 0) {
+ return `+${label.value}`;
+ }
+ if (label.value && label.value < 0) {
+ return label.value;
+ }
+ return '';
+ }
+
+ _computeRepoUrl(change?: ChangeInfo) {
+ if (!change) return '';
+ return GerritNav.getUrlForProjectChanges(
+ change.project,
+ true,
+ change.internalHost
+ );
+ }
+
+ _computeRepoBranchURL(change?: ChangeInfo) {
+ if (!change) return '';
+ return GerritNav.getUrlForBranch(
+ change.branch,
+ change.project,
+ undefined,
+ change.internalHost
+ );
+ }
+
+ _computeTopicURL(change?: ChangeInfo) {
+ if (!change?.topic) {
+ return '';
+ }
+ return GerritNav.getUrlForTopic(change.topic, 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.
+ */
+ _computeRepoDisplay(change: ChangeInfo | undefined, truncate: boolean) {
+ if (!change?.project) {
+ return '';
+ }
+ let str = '';
+ if (change.internalHost) {
+ str += change.internalHost + '/';
+ }
+ str += truncate ? truncatePath(change.project, 2) : change.project;
+ return str;
+ }
+
+ _computeSizeTooltip(change?: ChangeInfo) {
+ if (
+ !change ||
+ change.insertions + change.deletions === 0 ||
+ isNaN(change.insertions + change.deletions)
+ ) {
+ return 'Size unknown';
+ } else {
+ return `added ${change.insertions}, removed ${change.deletions} lines`;
+ }
+ }
+
+ _hasAttention(account: AccountInfo) {
+ if (!this.change || !this.change.attention_set) return false;
+ return hasOwnProperty(this.change.attention_set, account._account_id);
+ }
+
+ /**
+ * Computes the array of all reviewers with sorting the reviewers in the
+ * attention set before others, and the current user first.
+ */
+ _computeReviewers(change?: ChangeInfo) {
+ if (!change?.reviewers || !change?.reviewers.REVIEWER) return [];
+ const reviewers = [...change.reviewers.REVIEWER].filter(
+ r =>
+ (!change.owner || change.owner._account_id !== r._account_id) &&
+ !isServiceUser(r)
+ );
+ reviewers.sort((r1, r2) => {
+ if (this.account) {
+ if (r1._account_id === this.account._account_id) return -1;
+ if (r2._account_id === this.account._account_id) return 1;
+ }
+ if (this._hasAttention(r1) && !this._hasAttention(r2)) return -1;
+ if (this._hasAttention(r2) && !this._hasAttention(r1)) return 1;
+ return (r1.name || '').localeCompare(r2.name || '');
+ });
+ return reviewers;
+ }
+
+ _computePrimaryReviewers(change?: ChangeInfo) {
+ return this._computeReviewers(change).slice(0, PRIMARY_REVIEWERS_COUNT);
+ }
+
+ _computeAdditionalReviewers(change?: ChangeInfo) {
+ return this._computeReviewers(change).slice(PRIMARY_REVIEWERS_COUNT);
+ }
+
+ _computeAdditionalReviewersCount(change?: ChangeInfo) {
+ return this._computeAdditionalReviewers(change).length;
+ }
+
+ _computeAdditionalReviewersTitle(
+ change: ChangeInfo | undefined,
+ config: ServerInfo
+ ) {
+ if (!change || !config) return '';
+ return this._computeAdditionalReviewers(change)
+ .map(user => getDisplayName(config, user, true))
+ .join(', ');
+ }
+
+ _computeComments(unresolved_comment_count?: number) {
+ if (!unresolved_comment_count || unresolved_comment_count < 1) return '';
+ return `${unresolved_comment_count} unresolved`;
+ }
+
+ /**
+ * TShirt sizing is based on the following paper:
+ * http://dirkriehle.com/wp-content/uploads/2008/09/hicss-42-csdistr-final-web.pdf
+ */
+ _computeChangeSize(change?: ChangeInfo) {
+ if (!change) return null;
+ const delta = change.insertions + change.deletions;
+ if (isNaN(delta) || delta === 0) {
+ return null; // Unknown
+ }
+ if (delta < CHANGE_SIZE.XS) {
+ return 'XS';
+ } else if (delta < CHANGE_SIZE.SMALL) {
+ return 'S';
+ } else if (delta < CHANGE_SIZE.MEDIUM) {
+ return 'M';
+ } else if (delta < CHANGE_SIZE.LARGE) {
+ return 'L';
+ } else {
+ return 'XL';
+ }
+ }
+
+ toggleReviewed() {
+ const newVal = !this.change?.reviewed;
+ this.set('change.reviewed', newVal);
+ this.dispatchEvent(
+ new CustomEvent('toggle-reviewed', {
+ bubbles: true,
+ composed: true,
+ detail: {change: this.change, reviewed: newVal},
+ })
+ );
+ }
+
+ _handleChangeClick() {
+ // Don't prevent the default and neither stop bubbling. We just want to
+ // report the click, but then let the browser handle the click on the link.
+
+ const selfId = (this.account && this.account._account_id) || -1;
+ const ownerId =
+ (this.change && this.change.owner && this.change.owner._account_id) || -1;
+
+ this.reporting.reportInteraction('change-row-clicked', {
+ section: this.sectionName,
+ isOwner: selfId === ownerId,
+ });
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'gr-change-list-item': GrChangeListItem;
+ }
+}
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item_test.js b/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item_test.js
index 6d51310..1970928 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item_test.js
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item_test.js
@@ -278,7 +278,7 @@
assert.deepEqual(GerritNav.getUrlForProjectChanges.lastCall.args,
[change.project, true, change.internalHost]);
assert.deepEqual(GerritNav.getUrlForBranch.lastCall.args,
- [change.branch, change.project, null, change.internalHost]);
+ [change.branch, change.project, undefined, change.internalHost]);
assert.deepEqual(GerritNav.getUrlForTopic.lastCall.args,
[change.topic, change.internalHost]);
});
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog.js b/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog.js
deleted file mode 100644
index 9489b94..0000000
--- a/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog.js
+++ /dev/null
@@ -1,211 +0,0 @@
-/**
- * @license
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import '../../shared/gr-dialog/gr-dialog.js';
-import '../../../styles/shared-styles.js';
-import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator.js';
-import '../../shared/gr-js-api-interface/gr-js-api-interface.js';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
-import {PolymerElement} from '@polymer/polymer/polymer-element.js';
-import {htmlTemplate} from './gr-confirm-revert-dialog_html.js';
-
-const ERR_COMMIT_NOT_FOUND =
- 'Unable to find the commit hash of this change.';
-const CHANGE_SUBJECT_LIMIT = 50;
-
-// TODO(dhruvsri): clean up repeated definitions after moving to js modules
-const REVERT_TYPES = {
- REVERT_SINGLE_CHANGE: 1,
- REVERT_SUBMISSION: 2,
-};
-
-/**
- * @extends PolymerElement
- */
-class GrConfirmRevertDialog extends GestureEventListeners(
- LegacyElementMixin(PolymerElement)) {
- static get template() { return htmlTemplate; }
-
- static get is() { return 'gr-confirm-revert-dialog'; }
- /**
- * Fired when the confirm button is pressed.
- *
- * @event confirm
- */
-
- /**
- * Fired when the cancel button is pressed.
- *
- * @event cancel
- */
-
- static get properties() {
- return {
- /* The revert message updated by the user
- The default value is set by the dialog */
- _message: String,
- _revertType: {
- type: Number,
- value: REVERT_TYPES.REVERT_SINGLE_CHANGE,
- },
- _showRevertSubmission: {
- type: Boolean,
- value: false,
- },
- _changesCount: Number,
- _showErrorMessage: {
- type: Boolean,
- value: false,
- },
- /* store the default revert messages per revert type so that we can
- check if user has edited the revert message or not
- Set when populate() is called */
- _originalRevertMessages: {
- type: Array,
- value() { return []; },
- },
- // Store the actual messages that the user has edited
- _revertMessages: {
- type: Array,
- value() { return []; },
- },
- };
- }
-
- _computeIfSingleRevert(revertType) {
- return revertType === REVERT_TYPES.REVERT_SINGLE_CHANGE;
- }
-
- _computeIfRevertSubmission(revertType) {
- return revertType === REVERT_TYPES.REVERT_SUBMISSION;
- }
-
- _modifyRevertMsg(change, commitMessage, message) {
- return this.$.jsAPI.modifyRevertMsg(change,
- message, commitMessage);
- }
-
- populate(change, commitMessage, changes) {
- this._changesCount = changes.length;
- // The option to revert a single change is always available
- this._populateRevertSingleChangeMessage(
- change, commitMessage, change.current_revision);
- this._populateRevertSubmissionMessage(change, changes, commitMessage);
- }
-
- _populateRevertSingleChangeMessage(change, commitMessage, commitHash) {
- // Figure out what the revert title should be.
- const originalTitle = (commitMessage || '').split('\n')[0];
- const revertTitle = `Revert "${originalTitle}"`;
- if (!commitHash) {
- this.dispatchEvent(new CustomEvent('show-alert', {
- detail: {message: ERR_COMMIT_NOT_FOUND},
- composed: true, bubbles: true,
- }));
- return;
- }
- const revertCommitText = `This reverts commit ${commitHash}.`;
-
- this._message = `${revertTitle}\n\n${revertCommitText}\n\n` +
- `Reason for revert: <INSERT REASONING HERE>\n`;
- // This is to give plugins a chance to update message
- this._message = this._modifyRevertMsg(change, commitMessage,
- this._message);
- this._revertType = REVERT_TYPES.REVERT_SINGLE_CHANGE;
- this._showRevertSubmission = false;
- this._revertMessages[this._revertType] = this._message;
- this._originalRevertMessages[this._revertType] = this._message;
- }
-
- _getTrimmedChangeSubject(subject) {
- if (!subject) return '';
- if (subject.length < CHANGE_SUBJECT_LIMIT) return subject;
- return subject.substring(0, CHANGE_SUBJECT_LIMIT) + '...';
- }
-
- _modifyRevertSubmissionMsg(change, msg, commitMessage) {
- return this.$.jsAPI.modifyRevertSubmissionMsg(change, msg,
- commitMessage);
- }
-
- _populateRevertSubmissionMessage(change, changes, commitMessage) {
- // Follow the same convention of the revert
- const commitHash = change.current_revision;
- if (!commitHash) {
- this.dispatchEvent(new CustomEvent('show-alert', {
- detail: {message: ERR_COMMIT_NOT_FOUND},
- composed: true, bubbles: true,
- }));
- return;
- }
- if (!changes || changes.length <= 1) return;
- const submissionId = change.submission_id;
- const revertTitle = 'Revert submission ' + submissionId;
- this._message = revertTitle + '\n\n' + 'Reason for revert: <INSERT ' +
- 'REASONING HERE>\n';
- this._message += 'Reverted Changes:\n';
- changes.forEach(change => {
- this._message += change.change_id.substring(0, 10) + ':'
- + this._getTrimmedChangeSubject(change.subject) + '\n';
- });
- this._message = this._modifyRevertSubmissionMsg(change, this._message,
- commitMessage);
- this._revertType = REVERT_TYPES.REVERT_SUBMISSION;
- this._revertMessages[this._revertType] = this._message;
- this._originalRevertMessages[this._revertType] = this._message;
- this._showRevertSubmission = true;
- }
-
- _handleRevertSingleChangeClicked() {
- this._showErrorMessage = false;
- this._revertMessages[REVERT_TYPES.REVERT_SUBMISSION] = this._message;
- this._message = this._revertMessages[REVERT_TYPES.REVERT_SINGLE_CHANGE];
- this._revertType = REVERT_TYPES.REVERT_SINGLE_CHANGE;
- }
-
- _handleRevertSubmissionClicked() {
- this._showErrorMessage = false;
- this._revertType = REVERT_TYPES.REVERT_SUBMISSION;
- this._revertMessages[REVERT_TYPES.REVERT_SINGLE_CHANGE] = this._message;
- this._message = this._revertMessages[REVERT_TYPES.REVERT_SUBMISSION];
- }
-
- _handleConfirmTap(e) {
- e.preventDefault();
- e.stopPropagation();
- if (this._message === this._originalRevertMessages[this._revertType]) {
- this._showErrorMessage = true;
- return;
- }
- this.dispatchEvent(new CustomEvent('confirm', {
- detail: {revertType: this._revertType,
- message: this._message},
- composed: true, bubbles: false,
- }));
- }
-
- _handleCancelTap(e) {
- e.preventDefault();
- e.stopPropagation();
- this.dispatchEvent(new CustomEvent('cancel', {
- detail: {revertType: this._revertType},
- composed: true, bubbles: false,
- }));
- }
-}
-
-customElements.define(GrConfirmRevertDialog.is, GrConfirmRevertDialog);
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog.ts b/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog.ts
new file mode 100644
index 0000000..beaf0f8
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog.ts
@@ -0,0 +1,248 @@
+/**
+ * @license
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import '../../shared/gr-dialog/gr-dialog';
+import '../../../styles/shared-styles';
+import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator';
+import '../../shared/gr-js-api-interface/gr-js-api-interface';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
+import {PolymerElement} from '@polymer/polymer/polymer-element';
+import {htmlTemplate} from './gr-confirm-revert-dialog_html';
+import {customElement, property} from '@polymer/decorators';
+import {JsApiService} from '../../shared/gr-js-api-interface/gr-js-api-types';
+import {ChangeInfo, CommitId} from '../../../types/common';
+
+const ERR_COMMIT_NOT_FOUND = 'Unable to find the commit hash of this change.';
+const CHANGE_SUBJECT_LIMIT = 50;
+
+// TODO(dhruvsri): clean up repeated definitions after moving to js modules
+const REVERT_TYPES = {
+ REVERT_SINGLE_CHANGE: 1,
+ REVERT_SUBMISSION: 2,
+};
+
+export interface GrConfirmRevertDialog {
+ $: {
+ jsAPI: JsApiService & Element;
+ };
+}
+@customElement('gr-confirm-revert-dialog')
+export class GrConfirmRevertDialog extends GestureEventListeners(
+ LegacyElementMixin(PolymerElement)
+) {
+ static get template() {
+ return htmlTemplate;
+ }
+
+ /**
+ * Fired when the confirm button is pressed.
+ *
+ * @event confirm
+ */
+
+ /**
+ * Fired when the cancel button is pressed.
+ *
+ * @event cancel
+ */
+
+ /* The revert message updated by the user
+ The default value is set by the dialog */
+ @property({type: String})
+ _message?: string;
+
+ @property({type: Number})
+ _revertType = REVERT_TYPES.REVERT_SINGLE_CHANGE;
+
+ @property({type: Boolean})
+ _showRevertSubmission = false;
+
+ @property({type: Number})
+ _changesCount?: number;
+
+ @property({type: Boolean})
+ _showErrorMessage = false;
+
+ /* store the default revert messages per revert type so that we can
+ check if user has edited the revert message or not
+ Set when populate() is called */
+ @property({type: Array})
+ _originalRevertMessages: string[] = [];
+
+ // Store the actual messages that the user has edited
+ @property({type: Array})
+ _revertMessages: string[] = [];
+
+ _computeIfSingleRevert(revertType: number) {
+ return revertType === REVERT_TYPES.REVERT_SINGLE_CHANGE;
+ }
+
+ _computeIfRevertSubmission(revertType: number) {
+ return revertType === REVERT_TYPES.REVERT_SUBMISSION;
+ }
+
+ _modifyRevertMsg(change: ChangeInfo, commitMessage: string, message: string) {
+ return this.$.jsAPI.modifyRevertMsg(change, message, commitMessage);
+ }
+
+ populate(change: ChangeInfo, commitMessage: string, changes: ChangeInfo[]) {
+ this._changesCount = changes.length;
+ // The option to revert a single change is always available
+ this._populateRevertSingleChangeMessage(
+ change,
+ commitMessage,
+ change.current_revision
+ );
+ this._populateRevertSubmissionMessage(change, changes, commitMessage);
+ }
+
+ _populateRevertSingleChangeMessage(
+ change: ChangeInfo,
+ commitMessage: string,
+ commitHash?: CommitId
+ ) {
+ // Figure out what the revert title should be.
+ const originalTitle = (commitMessage || '').split('\n')[0];
+ const revertTitle = `Revert "${originalTitle}"`;
+ if (!commitHash) {
+ this.dispatchEvent(
+ new CustomEvent('show-alert', {
+ detail: {message: ERR_COMMIT_NOT_FOUND},
+ composed: true,
+ bubbles: true,
+ })
+ );
+ return;
+ }
+ const revertCommitText = `This reverts commit ${commitHash}.`;
+
+ const message =
+ `${revertTitle}\n\n${revertCommitText}\n\n` +
+ 'Reason for revert: <INSERT REASONING HERE>\n';
+ // This is to give plugins a chance to update message
+ this._message = this._modifyRevertMsg(change, commitMessage, message);
+ this._revertType = REVERT_TYPES.REVERT_SINGLE_CHANGE;
+ this._showRevertSubmission = false;
+ this._revertMessages[this._revertType] = this._message;
+ this._originalRevertMessages[this._revertType] = this._message;
+ }
+
+ _getTrimmedChangeSubject(subject: string) {
+ if (!subject) return '';
+ if (subject.length < CHANGE_SUBJECT_LIMIT) return subject;
+ return subject.substring(0, CHANGE_SUBJECT_LIMIT) + '...';
+ }
+
+ _modifyRevertSubmissionMsg(
+ change: ChangeInfo,
+ msg: string,
+ commitMessage: string
+ ) {
+ return this.$.jsAPI.modifyRevertSubmissionMsg(change, msg, commitMessage);
+ }
+
+ _populateRevertSubmissionMessage(
+ change: ChangeInfo,
+ changes: ChangeInfo[],
+ commitMessage: string
+ ) {
+ // Follow the same convention of the revert
+ const commitHash = change.current_revision;
+ if (!commitHash) {
+ this.dispatchEvent(
+ new CustomEvent('show-alert', {
+ detail: {message: ERR_COMMIT_NOT_FOUND},
+ composed: true,
+ bubbles: true,
+ })
+ );
+ return;
+ }
+ if (!changes || changes.length <= 1) return;
+ const revertTitle = `Revert submission ${change.submission_id}`;
+ let message =
+ revertTitle +
+ '\n\n' +
+ 'Reason for revert: <INSERT ' +
+ 'REASONING HERE>\n';
+ message += 'Reverted Changes:\n';
+ changes.forEach(change => {
+ message +=
+ `${change.change_id.substring(0, 10)}:` +
+ `${this._getTrimmedChangeSubject(change.subject)}\n`;
+ });
+ this._message = this._modifyRevertSubmissionMsg(
+ change,
+ message,
+ commitMessage
+ );
+ this._revertType = REVERT_TYPES.REVERT_SUBMISSION;
+ this._revertMessages[this._revertType] = this._message;
+ this._originalRevertMessages[this._revertType] = this._message;
+ this._showRevertSubmission = true;
+ }
+
+ _handleRevertSingleChangeClicked() {
+ this._showErrorMessage = false;
+ if (this._message)
+ this._revertMessages[REVERT_TYPES.REVERT_SUBMISSION] = this._message;
+ this._message = this._revertMessages[REVERT_TYPES.REVERT_SINGLE_CHANGE];
+ this._revertType = REVERT_TYPES.REVERT_SINGLE_CHANGE;
+ }
+
+ _handleRevertSubmissionClicked() {
+ this._showErrorMessage = false;
+ this._revertType = REVERT_TYPES.REVERT_SUBMISSION;
+ if (this._message)
+ this._revertMessages[REVERT_TYPES.REVERT_SINGLE_CHANGE] = this._message;
+ this._message = this._revertMessages[REVERT_TYPES.REVERT_SUBMISSION];
+ }
+
+ _handleConfirmTap(e: MouseEvent) {
+ e.preventDefault();
+ e.stopPropagation();
+ if (this._message === this._originalRevertMessages[this._revertType]) {
+ this._showErrorMessage = true;
+ return;
+ }
+ this.dispatchEvent(
+ new CustomEvent('confirm', {
+ detail: {revertType: this._revertType, message: this._message},
+ composed: true,
+ bubbles: false,
+ })
+ );
+ }
+
+ _handleCancelTap(e: MouseEvent) {
+ e.preventDefault();
+ e.stopPropagation();
+ this.dispatchEvent(
+ new CustomEvent('cancel', {
+ detail: {revertType: this._revertType},
+ composed: true,
+ bubbles: false,
+ })
+ );
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'gr-confirm-revert-dialog': GrConfirmRevertDialog;
+ }
+}
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog.js b/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog.js
deleted file mode 100644
index 42afcc2..0000000
--- a/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog.js
+++ /dev/null
@@ -1,92 +0,0 @@
-/**
- * @license
- * Copyright (C) 2018 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import '@polymer/iron-icon/iron-icon.js';
-import '../../shared/gr-icons/gr-icons.js';
-import '../../shared/gr-dialog/gr-dialog.js';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
-import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator.js';
-import '../../plugins/gr-endpoint-param/gr-endpoint-param.js';
-import '../../../styles/shared-styles.js';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
-import {PolymerElement} from '@polymer/polymer/polymer-element.js';
-import {htmlTemplate} from './gr-confirm-submit-dialog_html.js';
-
-/** @extends PolymerElement */
-class GrConfirmSubmitDialog extends GestureEventListeners(
- LegacyElementMixin(
- PolymerElement)) {
- static get template() { return htmlTemplate; }
-
- static get is() { return 'gr-confirm-submit-dialog'; }
- /**
- * Fired when the confirm button is pressed.
- *
- * @event confirm
- */
-
- /**
- * Fired when the cancel button is pressed.
- *
- * @event cancel
- */
-
- static get properties() {
- return {
- /**
- * @type {Gerrit.Change}
- */
- change: Object,
-
- /**
- * @type {{
- * label: string,
- * }}
- */
- action: Object,
- };
- }
-
- resetFocus(e) {
- this.$.dialog.resetFocus();
- }
-
- _computeHasChangeEdit(change) {
- return !!change.revisions &&
- Object.values(change.revisions).some(rev => rev._number == 'edit');
- }
-
- _computeUnresolvedCommentsWarning(change) {
- const unresolvedCount = change.unresolved_comment_count;
- const plural = unresolvedCount > 1 ? 's' : '';
- return `Heads Up! ${unresolvedCount} unresolved comment${plural}.`;
- }
-
- _handleConfirmTap(e) {
- e.preventDefault();
- e.stopPropagation();
- this.dispatchEvent(new CustomEvent('confirm', {bubbles: false}));
- }
-
- _handleCancelTap(e) {
- e.preventDefault();
- e.stopPropagation();
- this.dispatchEvent(new CustomEvent('cancel', {bubbles: false}));
- }
-}
-
-customElements.define(GrConfirmSubmitDialog.is, GrConfirmSubmitDialog);
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog.ts b/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog.ts
new file mode 100644
index 0000000..666f95d
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog.ts
@@ -0,0 +1,98 @@
+/**
+ * @license
+ * Copyright (C) 2018 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import '@polymer/iron-icon/iron-icon';
+import '../../shared/gr-icons/gr-icons';
+import '../../shared/gr-dialog/gr-dialog';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
+import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator';
+import '../../plugins/gr-endpoint-param/gr-endpoint-param';
+import '../../../styles/shared-styles';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
+import {PolymerElement} from '@polymer/polymer/polymer-element';
+import {htmlTemplate} from './gr-confirm-submit-dialog_html';
+import {customElement, property} from '@polymer/decorators';
+import {ChangeInfo, ActionInfo} from '../../../types/common';
+import {GrDialog} from '../../shared/gr-dialog/gr-dialog';
+
+export interface GrConfirmSubmitDialog {
+ $: {
+ dialog: GrDialog;
+ };
+}
+@customElement('gr-confirm-submit-dialog')
+export class GrConfirmSubmitDialog extends GestureEventListeners(
+ LegacyElementMixin(PolymerElement)
+) {
+ static get template() {
+ return htmlTemplate;
+ }
+
+ /**
+ * Fired when the confirm button is pressed.
+ *
+ * @event confirm
+ */
+
+ /**
+ * Fired when the cancel button is pressed.
+ *
+ * @event cancel
+ */
+
+ @property({type: Object})
+ change?: ChangeInfo;
+
+ @property({type: Object})
+ action?: ActionInfo;
+
+ resetFocus() {
+ this.$.dialog.resetFocus();
+ }
+
+ _computeHasChangeEdit(change?: ChangeInfo) {
+ return (
+ !!change &&
+ !!change.revisions &&
+ Object.values(change.revisions).some(rev => rev._number === 'edit')
+ );
+ }
+
+ _computeUnresolvedCommentsWarning(change: ChangeInfo) {
+ const unresolvedCount = change.unresolved_comment_count;
+ const plural = unresolvedCount && unresolvedCount > 1 ? 's' : '';
+ return `Heads Up! ${unresolvedCount} unresolved comment${plural}.`;
+ }
+
+ _handleConfirmTap(e: MouseEvent) {
+ e.preventDefault();
+ e.stopPropagation();
+ this.dispatchEvent(new CustomEvent('confirm', {bubbles: false}));
+ }
+
+ _handleCancelTap(e: MouseEvent) {
+ e.preventDefault();
+ e.stopPropagation();
+ this.dispatchEvent(new CustomEvent('cancel', {bubbles: false}));
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'gr-confirm-submit-dialog': GrConfirmSubmitDialog;
+ }
+}
diff --git a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.js b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.js
index 6ecdabb..630681c 100644
--- a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.js
+++ b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.js
@@ -540,7 +540,7 @@
if (reviewer.account) {
reviewerId = reviewer.account._account_id || reviewer.account.email;
} else if (reviewer.group) {
- reviewerId = reviewer.group.id;
+ reviewerId = decodeURIComponent(reviewer.group.id);
confirmed = reviewer.group.confirmed;
}
return {reviewer: reviewerId, confirmed};
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 b45782f..5016c40 100644
--- a/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation.ts
+++ b/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation.ts
@@ -758,10 +758,11 @@
change: ChangeInfo,
filePath: string,
patchNum?: PatchSetNum,
- basePatchNum?: PatchSetNum
+ basePatchNum?: PatchSetNum,
+ lineNum?: number
) {
this._navigate(
- this.getUrlForDiff(change, filePath, patchNum, basePatchNum)
+ this.getUrlForDiff(change, filePath, patchNum, basePatchNum, lineNum)
);
},
diff --git a/polygerrit-ui/app/elements/core/gr-router/gr-router.ts b/polygerrit-ui/app/elements/core/gr-router/gr-router.ts
index 6f1bc60..aa2e4ce 100644
--- a/polygerrit-ui/app/elements/core/gr-router/gr-router.ts
+++ b/polygerrit-ui/app/elements/core/gr-router/gr-router.ts
@@ -72,6 +72,7 @@
DASHBOARD: /^\/dashboard\/(.+)$/,
CUSTOM_DASHBOARD: /^\/dashboard\/?$/,
PROJECT_DASHBOARD: /^\/p\/(.+)\/\+\/dashboard\/(.+)/,
+ LEGACY_PROJECT_DASHBOARD: /^\/projects\/(.+),dashboards\/(.+)/,
AGREEMENTS: /^\/settings\/agreements\/?/,
NEW_AGREEMENTS: /^\/settings\/new-agreement\/?/,
@@ -880,6 +881,11 @@
'_handleProjectDashboardRoute'
);
+ this._mapRoute(
+ RoutePattern.LEGACY_PROJECT_DASHBOARD,
+ '_handleLegacyProjectDashboardRoute'
+ );
+
this._mapRoute(RoutePattern.GROUP_INFO, '_handleGroupInfoRoute', true);
this._mapRoute(
@@ -1255,6 +1261,10 @@
this.reporting.setRepoName(project);
}
+ _handleLegacyProjectDashboardRoute(data: PageContextWithQueryMap) {
+ this._redirect('/p/' + data.params[0] + '/+/dashboard/' + data.params[1]);
+ }
+
_handleGroupInfoRoute(data: PageContextWithQueryMap) {
this._redirect('/admin/groups/' + encodeURIComponent(data.params[0]));
}
diff --git a/polygerrit-ui/app/elements/core/gr-router/gr-router_test.js b/polygerrit-ui/app/elements/core/gr-router/gr-router_test.js
index 5ee58c9..927434b 100644
--- a/polygerrit-ui/app/elements/core/gr-router/gr-router_test.js
+++ b/polygerrit-ui/app/elements/core/gr-router/gr-router_test.js
@@ -197,6 +197,7 @@
'_handleImproperlyEncodedPlusRoute',
'_handlePassThroughRoute',
'_handleProjectDashboardRoute',
+ '_handleLegacyProjectDashboardRoute',
'_handleProjectsOldRoute',
'_handleRepoAccessRoute',
'_handleRepoDashboardsRoute',
@@ -617,6 +618,14 @@
handlePassThroughRoute = sinon.stub(element, '_handlePassThroughRoute');
});
+ test('_handleLegacyProjectDashboardRoute', () => {
+ const params = {0: 'gerrit/project', 1: 'dashboard:main'};
+ element._handleLegacyProjectDashboardRoute({params});
+ assert.isTrue(redirectStub.calledOnce);
+ assert.equal(redirectStub.lastCall.args[0],
+ '/p/gerrit/project/+/dashboard/dashboard:main');
+ });
+
test('_handleAgreementsRoute', () => {
const data = {params: {}};
element._handleAgreementsRoute(data);
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.js b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.js
index 2c4c2c9..8d57085 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.js
@@ -231,6 +231,8 @@
type: Object,
value: () => new Set(),
},
+ // line number on the diff which should be scrolled to upon loading
+ _focusLineNum: Number,
};
}
@@ -744,14 +746,12 @@
.then(files => files.has(path));
}
- _initLineOfInterestAndCursor(lineNum, leftSide) {
+ _initLineOfInterestAndCursor(leftSide) {
this.$.diffHost.lineOfInterest =
this._getLineOfInterest({
- lineNum,
leftSide,
});
this._initCursor({
- lineNum,
leftSide,
});
}
@@ -818,7 +818,7 @@
}
_initPatchRange() {
- let lineNum; let leftSide;
+ let leftSide;
if (this.params.commentId) {
const comment = this._changeComments.findCommentById(
this.params.commentId);
@@ -849,7 +849,7 @@
// comment.patch_set vs latest
leftSide = true;
}
- lineNum = comment.line;
+ this._focusLineNum = comment.line;
} else {
if (this.params.path) {
this._path = this.params.path;
@@ -861,11 +861,11 @@
};
}
if (this.params.lineNum) {
- lineNum = this.params.lineNum;
+ this._focusLineNum = this.params.lineNum;
leftSide = this.params.leftSide;
}
}
- this._initLineOfInterestAndCursor(lineNum, leftSide);
+ this._initLineOfInterestAndCursor(leftSide);
this._commentMap = this._getPaths(this._patchRange);
this._commentsForDiff = this._getCommentsForPath(this._path,
@@ -889,6 +889,7 @@
this._patchRange = undefined;
this._commitRange = undefined;
this._changeComments = undefined;
+ this._focusLineNum = undefined;
if (value.changeNum && value.project) {
this.$.restAPI.setInProjectLookup(value.changeNum, value.project);
@@ -956,7 +957,8 @@
composed: true, bubbles: true,
}));
GerritNav.navigateToDiff(
- this._change, this._path, this._patchRange.basePatchNum);
+ this._change, this._path, this._patchRange.basePatchNum,
+ 'PARENT', this._focusLineNum);
return;
}
if (value.commentLink) {
@@ -1010,20 +1012,20 @@
* If the params specify a diff address then configure the diff cursor.
*/
_initCursor(params) {
- if (params.lineNum === undefined) { return; }
+ if (this._focusLineNum === undefined) { return; }
if (params.leftSide) {
this.$.cursor.side = DiffSides.LEFT;
} else {
this.$.cursor.side = DiffSides.RIGHT;
}
- this.$.cursor.initialLineNumber = params.lineNum;
+ this.$.cursor.initialLineNumber = this._focusLineNum;
}
_getLineOfInterest(params) {
// If there is a line number specified, pass it along to the diff so that
// it will not get collapsed.
- if (!params.lineNum) { return null; }
- return {number: params.lineNum, leftSide: params.leftSide};
+ if (!this._focusLineNum) { return null; }
+ return {number: this._focusLineNum, leftSide: params.leftSide};
}
_pathChanged(path) {
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.js b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.js
index 627d600..62963c8 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.js
@@ -169,7 +169,8 @@
element._change = generateChange({revisionsCount: 11});
return element._paramsChanged.returnValues[0].then(() => {
assert.isTrue(initLineOfInterestAndCursorStub.
- calledWithExactly(10, true));
+ calledWithExactly(true));
+ assert.equal(element._focusLineNum, 10);
assert.equal(element._patchRange.patchNum, 11);
assert.equal(element._patchRange.basePatchNum, 2);
});
@@ -226,7 +227,7 @@
element._change = generateChange({revisionsCount: 11});
return element._paramsChanged.returnValues[0].then(() => {
assert.isTrue(diffNavStub.lastCall.calledWithExactly(
- element._change, '/COMMIT_MSG', 2));
+ element._change, '/COMMIT_MSG', 2, 'PARENT', 10));
});
});
@@ -1218,17 +1219,21 @@
assert.isNotOk(element.$.cursor.initialLineNumber);
// Revision hash: specifies lineNum but not side.
- element._initCursor({lineNum: 234});
+
+ element._focusLineNum = 234;
+ element._initCursor({});
assert.equal(element.$.cursor.initialLineNumber, 234);
assert.equal(element.$.cursor.side, 'right');
// Base hash: specifies lineNum and side.
- element._initCursor({leftSide: true, lineNum: 345});
+ element._focusLineNum = 345;
+ element._initCursor({leftSide: true});
assert.equal(element.$.cursor.initialLineNumber, 345);
assert.equal(element.$.cursor.side, 'left');
// Specifies right side:
- element._initCursor({leftSide: false, lineNum: 123});
+ element._focusLineNum = 123;
+ element._initCursor({leftSide: false});
assert.equal(element.$.cursor.initialLineNumber, 123);
assert.equal(element.$.cursor.side, 'right');
});
@@ -1236,11 +1241,12 @@
test('_getLineOfInterest', () => {
assert.isNull(element._getLineOfInterest({}));
- let result = element._getLineOfInterest({lineNum: 12});
+ element._focusLineNum = 12;
+ let result = element._getLineOfInterest({});
assert.equal(result.number, 12);
assert.isNotOk(result.leftSide);
- result = element._getLineOfInterest({lineNum: 12, leftSide: true});
+ result = element._getLineOfInterest({leftSide: true});
assert.equal(result.number, 12);
assert.isOk(result.leftSide);
});
diff --git a/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard-behavior.ts b/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard-behavior.ts
index affa8b5..c4ebe1e 100644
--- a/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard-behavior.ts
+++ b/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard-behavior.ts
@@ -24,6 +24,10 @@
import {dedupingMixin} from '@polymer/polymer/lib/utils/mixin';
import {property, observe} from '@polymer/decorators';
import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
+import {
+ pushScrollLock,
+ removeScrollLock,
+} from '@polymer/iron-overlay-behavior/iron-scroll-manager';
const HOVER_CLASS = 'hovered';
const HIDE_CLASS = 'hide';
@@ -127,8 +131,10 @@
// show the hovercard if mouse moves to hovercard
// this will cancel pending hide as well
this.listen(this, 'mouseenter', 'show');
+ this.listen(this, 'mouseenter', 'lock');
// when leave hovercard, hide it immediately
this.listen(this, 'mouseleave', 'hide');
+ this.listen(this, 'mouseleave', 'unlock');
}
/** @override */
@@ -213,6 +219,13 @@
}
/**
+ * unlock scroll, this will resume the scroll outside of the hovercard.
+ */
+ unlock() {
+ removeScrollLock(this);
+ }
+
+ /**
* Hides/closes the hovercard. This occurs when the user triggers the
* `mouseleave` event on the hovercard's `target` element (as long as the
* user is not hovering over the hovercard).
@@ -287,6 +300,13 @@
}
/**
+ * Lock background scroll but enable scroll inside of current hovercard.
+ */
+ lock() {
+ pushScrollLock(this);
+ }
+
+ /**
* Shows/opens the hovercard. This occurs when the user triggers the
* `mousenter` event on the hovercard's `target` element.
*/
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-types.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-types.ts
index 7455cad..0a5dbc9 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-types.ts
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-types.ts
@@ -39,5 +39,10 @@
origMsg: string
): string;
handleEvent(eventName: EventType, detail: any): void;
+ modifyRevertMsg(
+ change: ChangeInfo,
+ revertMsg: string,
+ origMsg: string
+ ): string;
// TODO(TS): Add more methods when needed for the TS conversion.
}
diff --git a/polygerrit-ui/app/services/services/gr-rest-api/gr-rest-api.ts b/polygerrit-ui/app/services/services/gr-rest-api/gr-rest-api.ts
index e3c2ce8..d172f2a 100644
--- a/polygerrit-ui/app/services/services/gr-rest-api/gr-rest-api.ts
+++ b/polygerrit-ui/app/services/services/gr-rest-api/gr-rest-api.ts
@@ -75,6 +75,8 @@
ProjectInput,
AccountId,
ChangeMessageId,
+ GroupAuditEventInfo,
+ EncodedGroupId,
} from '../../../types/common';
import {ParsedChangeInfo} from '../../../elements/shared/gr-rest-api-interface/gr-reviewer-updates-parser';
import {HttpMethod} from '../../../constants/constants';
@@ -605,8 +607,42 @@
changeNum: ChangeNum,
messageId: ChangeMessageId
): Promise<Response>;
+
removeChangeReviewer(
changeNum: ChangeNum,
reviewerID: AccountId | GroupId
): Promise<Response | undefined>;
+
+ getGroupAuditLog(
+ group: EncodedGroupId,
+ errFn?: ErrorCallback
+ ): Promise<GroupAuditEventInfo[] | undefined>;
+
+ getGroupMembers(
+ groupName: GroupId,
+ errFn?: ErrorCallback
+ ): Promise<AccountInfo[] | undefined>;
+
+ getIncludedGroup(groupName: GroupId): Promise<GroupInfo[] | undefined>;
+
+ saveGroupMember(
+ groupName: GroupId,
+ groupMember: AccountId
+ ): Promise<AccountInfo>;
+
+ saveIncludedGroup(
+ groupName: GroupId,
+ includedGroup: GroupId,
+ errFn?: ErrorCallback
+ ): Promise<GroupInfo | undefined>;
+
+ deleteGroupMember(
+ groupName: GroupId,
+ groupMember: AccountId
+ ): Promise<Response>;
+
+ deleteIncludedGroup(
+ groupName: GroupId,
+ includedGroup: GroupId
+ ): Promise<Response>;
}
diff --git a/polygerrit-ui/app/types/types.ts b/polygerrit-ui/app/types/types.ts
index 8dd9371..970fa21 100644
--- a/polygerrit-ui/app/types/types.ts
+++ b/polygerrit-ui/app/types/types.ts
@@ -126,6 +126,8 @@
* The index of the element in the dom-repeat.
*/
index: number;
+ get: (name: string) => T;
+ set: (name: string, val: T) => void;
}
/** https://highlightjs.readthedocs.io/en/latest/api.html */
diff --git a/polygerrit-ui/app/utils/change-util.ts b/polygerrit-ui/app/utils/change-util.ts
index 298ae9d..de43884c 100644
--- a/polygerrit-ui/app/utils/change-util.ts
+++ b/polygerrit-ui/app/utils/change-util.ts
@@ -16,16 +16,7 @@
*/
import {getBaseUrl} from './url-util';
import {ChangeStatus} from '../constants/constants';
-import {LegacyChangeId, PatchSetNum} from '../types/common';
-
-// This can be wrong! See WARNING above
-interface Change {
- status: string; // This can be wrong! See WARNING above
- mergeable: boolean; // This can be wrong! See WARNING above
- work_in_progress: boolean; // This can be wrong! See WARNING above
- is_private: boolean; // This can be wrong! See WARNING above
- submittable: boolean; // This can be wrong! See WARNING above
-}
+import {LegacyChangeId, PatchSetNum, ChangeInfo} from '../types/common';
// This can be wrong! See WARNING above
interface ChangeStatusesOptions {
@@ -132,12 +123,12 @@
return `${getBaseUrl()}/c/${changeNum}`;
}
-export function changeIsOpen(change?: Change) {
+export function changeIsOpen(change?: ChangeInfo) {
return change?.status === ChangeStatus.NEW;
}
export function changeStatuses(
- change: Change,
+ change: ChangeInfo,
opt_options?: ChangeStatusesOptions
) {
const states = [];
@@ -175,6 +166,6 @@
return states;
}
-export function changeStatusString(change: Change) {
+export function changeStatusString(change: ChangeInfo) {
return changeStatuses(change).join(', ');
}